Compare commits

...

No commits in common. "master" and "merge/master" have entirely different histories.

440 changed files with 16090 additions and 54508 deletions

View File

@ -1,29 +0,0 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt -- --check
- name: Clippy
run: cargo clippy --workspace --all-features
- name: Test
run: cargo test --workspace --all-targets --all-features --no-fail-fast

12
.gitignore vendored
View File

@ -1,5 +1,4 @@
# ===== Rust / Cargo =====
.cargo/config.toml
/target/
**/target/
@ -11,7 +10,6 @@
# RustRover / IntelliJ
.idea/
*.iml
.junie
# VS Code
.vscode/
@ -57,12 +55,4 @@ dist-staging
dist-staging/**
temp
temp/**
**/build/**
**/node_modules/**
AGENTS.md
.python-version
.venv
temp/**

601
Cargo.lock generated
View File

@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"getrandom",
"once_cell",
"version_check",
"zerocopy",
@ -86,7 +86,7 @@ dependencies = [
"ndk-context",
"ndk-sys 0.6.0+11769913",
"num_enum",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -156,9 +156,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.101"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arrayref"
@ -288,7 +288,7 @@ dependencies = [
"polling",
"rustix 0.38.44",
"slab",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -303,6 +303,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.52"
@ -406,7 +415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
dependencies = [
"termcolor",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@ -456,6 +465,20 @@ dependencies = [
"memchr",
]
[[package]]
name = "compact_str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -525,6 +548,12 @@ dependencies = [
"bindgen",
]
[[package]]
name = "cow-utils"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
[[package]]
name = "cpal"
version = "0.15.3"
@ -604,6 +633,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dragonbox_ecma"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a5577f010d4e1bb3f3c4d6081e05718eb6992cf20119cab4d3abadff198b5ae"
[[package]]
name = "either"
version = "1.15.0"
@ -626,6 +661,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.7"
@ -669,17 +710,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -757,7 +787,7 @@ checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884"
dependencies = [
"log",
"presser",
"thiserror",
"thiserror 1.0.69",
"winapi",
"windows 0.52.0",
]
@ -797,6 +827,9 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
]
[[package]]
name = "hassle-rs"
@ -808,7 +841,7 @@ dependencies = [
"com",
"libc",
"libloading 0.8.9",
"thiserror",
"thiserror 1.0.69",
"widestring",
"winapi",
]
@ -873,7 +906,7 @@ dependencies = [
"combine",
"jni-sys",
"log",
"thiserror",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
@ -890,7 +923,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"getrandom",
"libc",
]
@ -1055,7 +1088,7 @@ dependencies = [
"rustc-hash 1.1.0",
"spirv",
"termcolor",
"thiserror",
"thiserror 1.0.69",
"unicode-xid",
]
@ -1070,7 +1103,7 @@ dependencies = [
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -1085,7 +1118,7 @@ dependencies = [
"ndk-sys 0.6.0+11769913",
"num_enum",
"raw-window-handle",
"thiserror",
"thiserror 1.0.69",
]
[[package]]
@ -1122,6 +1155,22 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nonmax"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.4.2"
@ -1133,6 +1182,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1440,6 +1498,212 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "owo-colors"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
[[package]]
name = "oxc-miette"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a7ba54c704edefead1f44e9ef09c43e5cfae666bdc33516b066011f0e6ebf7"
dependencies = [
"cfg-if",
"owo-colors",
"oxc-miette-derive",
"textwrap",
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]]
name = "oxc-miette-derive"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4faecb54d0971f948fbc1918df69b26007e6f279a204793669542e1e8b75eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "oxc_allocator"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2174c7c8f77137b1bd1c653d7a5a531ae41f3b8fec1dd0251c801689784e7a2e"
dependencies = [
"allocator-api2",
"hashbrown 0.16.1",
"oxc_data_structures",
"rustc-hash 2.1.1",
]
[[package]]
name = "oxc_ast"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f1902f97a5cac8767b76a1d8a1b3124e9db80c176ebbc98f75143dcc124a15"
dependencies = [
"bitflags 2.10.0",
"oxc_allocator",
"oxc_ast_macros",
"oxc_data_structures",
"oxc_diagnostics",
"oxc_estree",
"oxc_regular_expression",
"oxc_span",
"oxc_syntax",
]
[[package]]
name = "oxc_ast_macros"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a31bd55516a98a35b2d99fa5813a3d3a5b798bad3262c819dfe7344bc6f390"
dependencies = [
"phf",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "oxc_ast_visit"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2c520a488c04ba5267223edd0bb245fb7f10e2358e8955802a5d962bb95b50a"
dependencies = [
"oxc_allocator",
"oxc_ast",
"oxc_span",
"oxc_syntax",
]
[[package]]
name = "oxc_data_structures"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a42840ce8d83a08a92823dda6189e4d97359feca24a4fa732f3256c4614bb5a4"
[[package]]
name = "oxc_diagnostics"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4f7b09c1563a67ede53af131f717b31ba89a992959ebad188b5158c21d4dc0a"
dependencies = [
"cow-utils",
"oxc-miette",
"percent-encoding",
]
[[package]]
name = "oxc_ecmascript"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4813b352bd5b0b05badf0c9e6c5ec7ea58a6a7ab49bec8d18ead262624c6ef8d"
dependencies = [
"cow-utils",
"num-bigint",
"num-traits",
"oxc_allocator",
"oxc_ast",
"oxc_regular_expression",
"oxc_span",
"oxc_syntax",
]
[[package]]
name = "oxc_estree"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e54fb3effe995e6538d68070bf0a450b5ffd11dd41b62f11a4d01efa1f40e278"
[[package]]
name = "oxc_index"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b"
dependencies = [
"nonmax",
"serde",
]
[[package]]
name = "oxc_parser"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5592bf8b64743944eb46528f9eabdde2b2435c8293cd502f5c183f9dff644e16"
dependencies = [
"bitflags 2.10.0",
"cow-utils",
"memchr",
"num-bigint",
"num-traits",
"oxc_allocator",
"oxc_ast",
"oxc_data_structures",
"oxc_diagnostics",
"oxc_ecmascript",
"oxc_regular_expression",
"oxc_span",
"oxc_syntax",
"rustc-hash 2.1.1",
"seq-macro",
]
[[package]]
name = "oxc_regular_expression"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09de7f7e0fb82f54750e3a95346a828fd354b9aeac00f131719008733e66a18d"
dependencies = [
"bitflags 2.10.0",
"oxc_allocator",
"oxc_ast_macros",
"oxc_diagnostics",
"oxc_span",
"phf",
"rustc-hash 2.1.1",
"unicode-id-start",
]
[[package]]
name = "oxc_span"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a42c0759b745eca0fe776890af46ce12e79e61796995e51a8eb9dcdf5516ab0"
dependencies = [
"compact_str",
"oxc-miette",
"oxc_allocator",
"oxc_ast_macros",
"oxc_estree",
]
[[package]]
name = "oxc_syntax"
version = "0.110.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63eac2e04a75a10c5714aeb753cdfa06b1abc66bbaa748b7994700f52c9b184"
dependencies = [
"bitflags 2.10.0",
"cow-utils",
"dragonbox_ecma",
"nonmax",
"oxc_allocator",
"oxc_ast_macros",
"oxc_data_structures",
"oxc_estree",
"oxc_index",
"oxc_span",
"phf",
"unicode-id-start",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -1469,32 +1733,55 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbxgen-dummy-boy"
version = "0.1.0"
dependencies = [
"anyhow",
"prometeu-bytecode",
"prometeu-hal",
"serde_json",
]
[[package]]
name = "pbxgen-stress"
version = "0.1.0"
dependencies = [
"anyhow",
"prometeu-bytecode",
"prometeu-hal",
"serde_json",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@ -1530,7 +1817,7 @@ dependencies = [
"bytemuck",
"pollster",
"raw-window-handle",
"thiserror",
"thiserror 1.0.69",
"ultraviolet",
"wgpu",
]
@ -1576,15 +1863,6 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@ -1615,42 +1893,39 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
[[package]]
name = "prometeu"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"prometeu-compiler",
"prometeu-runtime-desktop",
]
[[package]]
name = "prometeu-bytecode"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "prometeu-cli"
name = "prometeu-compiler"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"prometeu-host-desktop-winit",
]
[[package]]
name = "prometeu-drivers"
version = "0.1.0"
dependencies = [
"prometeu-hal",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
"oxc_parser",
"oxc_span",
"prometeu-bytecode",
"prometeu-core",
"serde",
"serde_json",
]
[[package]]
name = "prometeu-firmware"
version = "0.1.0"
dependencies = [
"prometeu-bytecode",
"prometeu-drivers",
"prometeu-hal",
"prometeu-system",
"prometeu-vm",
]
[[package]]
name = "prometeu-hal"
name = "prometeu-core"
version = "0.1.0"
dependencies = [
"prometeu-bytecode",
@ -1659,62 +1934,17 @@ dependencies = [
]
[[package]]
name = "prometeu-host-desktop-winit"
name = "prometeu-runtime-desktop"
version = "0.1.0"
dependencies = [
"clap",
"cpal",
"pixels",
"prometeu-drivers",
"prometeu-firmware",
"prometeu-hal",
"prometeu-system",
"prometeu-core",
"ringbuf",
"serde_json",
"winit",
]
[[package]]
name = "prometeu-layer-tests"
version = "0.1.0"
dependencies = [
"prometeu-bytecode",
"prometeu-hal",
"prometeu-test-support",
"prometeu-vm",
]
[[package]]
name = "prometeu-quality-checks"
version = "0.1.0"
[[package]]
name = "prometeu-system"
version = "0.1.0"
dependencies = [
"prometeu-bytecode",
"prometeu-drivers",
"prometeu-hal",
"prometeu-vm",
"serde_json",
]
[[package]]
name = "prometeu-test-support"
version = "0.1.0"
dependencies = [
"rand",
]
[[package]]
name = "prometeu-vm"
version = "0.1.0"
dependencies = [
"prometeu-bytecode",
"prometeu-hal",
"prometeu-test-support",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@ -1739,36 +1969,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "range-alloc"
version = "0.1.4"
@ -1898,6 +2098,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "safe_arch"
version = "0.7.4"
@ -1941,6 +2147,12 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "seq-macro"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
[[package]]
name = "serde"
version = "1.0.228"
@ -1990,6 +2202,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.11"
@ -2011,6 +2229,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "smithay-client-toolkit"
version = "0.19.2"
@ -2025,7 +2249,7 @@ dependencies = [
"log",
"memmap2",
"rustix 0.38.44",
"thiserror",
"thiserror 1.0.69",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
@ -2103,13 +2327,33 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
@ -2123,6 +2367,17 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@ -2171,9 +2426,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.8+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
dependencies = [
"winnow",
]
@ -2209,12 +2464,24 @@ dependencies = [
"wide",
]
[[package]]
name = "unicode-id-start"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
@ -2227,6 +2494,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -2255,12 +2528,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
@ -2501,7 +2768,7 @@ dependencies = [
"raw-window-handle",
"rustc-hash 1.1.0",
"smallvec",
"thiserror",
"thiserror 1.0.69",
"web-sys",
"wgpu-hal",
"wgpu-types",
@ -2545,7 +2812,7 @@ dependencies = [
"renderdoc-sys",
"rustc-hash 1.1.0",
"smallvec",
"thiserror",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
"wgpu-types",

View File

@ -1,18 +1,10 @@
[workspace]
members = [
"crates/console/prometeu-bytecode",
"crates/console/prometeu-drivers",
"crates/console/prometeu-firmware",
"crates/console/prometeu-hal",
"crates/console/prometeu-system",
"crates/console/prometeu-vm",
"crates/host/prometeu-host-desktop-winit",
"crates/tools/prometeu-cli",
"crates/tools/pbxgen-dummy-boy",
"crates/tools/pbxgen-stress",
"crates/dev/prometeu-test-support",
"crates/dev/prometeu-layer-tests",
"crates/dev/prometeu-quality-checks",
"crates/prometeu-core",
"crates/prometeu-runtime-desktop",
"crates/prometeu",
"crates/prometeu-bytecode",
"crates/prometeu-compiler",
]
resolver = "2"

View File

@ -1,49 +0,0 @@
.PHONY: fmt fmt-check clippy test-local test-debugger-socket clean coverage coverage-xml coverage-json coverage-report-json coverage-domain-list coverage-domain-evidence coverage-domain-gate test ci ci-domains cobertura
fmt:
cargo fmt
fmt-check:
cargo fmt -- --check
clippy:
cargo clippy --workspace --all-features
test-local:
cargo test --workspace --all-targets --all-features --no-fail-fast
test-debugger-socket:
cargo test -p prometeu-host-desktop-winit --lib -- --ignored
clean:
cargo llvm-cov clean --workspace
coverage:
cargo llvm-cov --workspace --all-features --html --output-dir target/llvm-cov
coverage-report-json:
cargo llvm-cov report --json --output-path target/llvm-cov/report.json
coverage-xml:
cargo llvm-cov report --cobertura --output-path target/llvm-cov/cobertura.xml
coverage-json:
cargo llvm-cov report --json --summary-only --output-path target/llvm-cov/summary.json
coverage-domain-list:
./scripts/coverage-domain-evidence.sh --list
coverage-domain-evidence:
@if [ -z "$(DOMAIN)" ]; then \
echo "usage: make coverage-domain-evidence DOMAIN=<domain>"; \
exit 1; \
fi
./scripts/coverage-domain-evidence.sh "$(DOMAIN)"
coverage-domain-gate:
./scripts/coverage-domain-evidence.sh --check-all
test: fmt-check clippy test-local test-debugger-socket
ci: clean fmt-check clippy coverage coverage-report-json
ci-domains: clean fmt-check clippy coverage coverage-report-json coverage-xml coverage-json coverage-domain-gate
cobertura: coverage-xml coverage-json

196
README.md
View File

@ -1,188 +1,84 @@
# PROMETEU Runtime
# PROMETEU
PROMETEU is an educational and experimental fantasy handheld / fantasy console.
This repository contains the Rust runtime workspace for the machine: VM-facing components, virtual peripherals, firmware/system services, host integration, CLI entrypoints, development utilities, specs, and discussion artifacts.
PROMETEU is an **educational and experimental ecosystem** inspired by classic consoles, focusing on **teaching programming, system architecture, and hardware concepts through software**.
The VM is only one subsystem of the machine. The canonical docs in this repository intentionally distinguish:
> PROMETEU is a simple, explicit, and educational virtual machine.
- machine-level specs;
- VM/runtime internal architecture;
- implementation crates;
- discussion workflow and lessons learned.
---
## What This Repository Contains
## 🎯 Project Goals
- `crates/console/`: core machine/runtime crates such as `prometeu-vm`, `prometeu-system`, `prometeu-hal`, `prometeu-drivers`, and `prometeu-firmware`
- `crates/host/`: host-side execution surfaces, currently including `prometeu-host-desktop-winit`
- `crates/tools/`: user-facing and support binaries such as `prometeu` and `pbxgen-stress`
- `crates/dev/`: test support, layer tests, and quality-check utilities
- `docs/specs/runtime/`: canonical PROMETEU machine specs
- `docs/vm-arch/`: canonical VM/runtime architecture and ISA references
- `devtools/`: debugger protocol material
- `discussion/`: agendas, decisions, plans, and lessons for architectural work
- `test-cartridges/`: cartridge fixtures used for validation and manual runs
- **Simulate simple "logical hardware"**: Create a low entry barrier for understanding how computers work.
- **Deterministic Loop**: Ensure the same code produces the same result on any platform.
- **Total Portability**: The core does not depend on an operating system, allowing it to run from modern computers to dedicated hardware.
- **First-Class Tools**: Offer deep debugging and inspection as a central part of the experience.
## Canonical Documentation
---
Use these entrypoints instead of inferring the model from isolated source files:
## 🧠 Design Philosophy
- [Machine specs](docs/specs/runtime/README.md): authoritative contract for the PROMETEU machine, peripherals, firmware, cartridge format, timing, and host ABI
- [VM architecture](docs/vm-arch/ARCHITECTURE.md): authoritative internal architecture for VM/runtime invariants
- [ISA reference](docs/vm-arch/ISA_CORE.md): bytecode-level instruction set authority
- [Discussion workflow](discussion/index.ndjson): architectural agenda, decision, and execution traceability
- **No magic**: everything is explicit.
- **No implicit heuristics**: the system doesn't "guess intentions".
- **Deterministic**: same input → same result.
- **Hardware-first**: APIs model peripherals, not modern frameworks.
- **Portable by definition**: if it doesn't work on all platforms, it doesn't exist.
## Workspace Layout
---
This repository is a Rust workspace rooted at [Cargo.toml](Cargo.toml) with members in:
## 📦 Monorepo Structure
- `crates/console`
- `crates/host`
- `crates/tools`
- `crates/dev`
This repository is organized as a Rust workspace and contains several components:
The main user-facing dispatcher binary is `prometeu`, built from `crates/tools/prometeu-cli`.
The same package also builds `prometeu-runtime`, which embeds the current desktop host runtime.
- **[crates/](./crates)**: Software implementation in Rust.
- **[prometeu](./crates/prometeu)**: Unified command-line interface (CLI).
- **[prometeu-core](./crates/prometeu-core)**: The logical core, VM, and internal OS.
- **[prometeu-runtime-desktop](crates/prometeu-runtime-desktop)**: Host for execution on Desktop systems.
- **[docs/](./docs)**: Technical documentation and system specifications.
- **[devtools-protocol/](devtools)**: Definition of the communication protocol for development tools.
- **[test-cartridges/](./test-cartridges)**: Cartridge examples and test suites.
Current CLI surface:
---
- `prometeu run <cart>`
- `prometeu debug <cart> --port <port>`
## 🛠️ Requirements
The dispatcher forwards runtime execution to `prometeu-runtime`.
For lower-level local runs, `prometeu-runtime` also accepts:
- **Rust**: Version defined in `rust-toolchain.toml`.
- **Installation**: Use [rustup](https://rustup.rs/) to install the required toolchain.
- `--run <cart>`
- `--debug <cart> --port <port>`
- `--fs-root <path>`
- `--cap <path>`
---
Other ecosystem commands, such as build, pack, and verification tooling, are not part of the current dispatcher surface in this workspace.
## ▶️ Quick Start
## Requirements
- Rust toolchain from [rust-toolchain.toml](rust-toolchain.toml)
- `rustup` for toolchain installation and management
- `cargo-llvm-cov` for coverage targets such as `make coverage` and `make ci-domains`
- a local graphical environment for desktop runtime commands
## Quick Start
Build the workspace:
To compile the full project:
```bash
cargo build
```
Inspect the CLI:
To run an example cartridge:
```bash
cargo run -q -p prometeu-cli --bin prometeu -- --help
./target/debug/prometeu run test-cartridges/color-square
```
Run the current stress cartridge fixture:
For more details on how to use the CLI, see the **[prometeu](./crates/prometeu)** README.
```bash
cargo run -q -p prometeu-cli --bin prometeu -- run test-cartridges/stress-console
```
---
Run the embedded runtime binary directly:
## 🚧 Project Status
```bash
cargo run -q -p prometeu-cli --bin prometeu-runtime -- --run test-cartridges/stress-console
```
⚠️ **Early stage (bootstrap)**
Regenerate and run the stress cartridge fixture:
Currently, the focus is on stabilizing the core architecture and debugging protocol. Nothing here should be considered a stable API yet.
```bash
./scripts/run-stress.sh
```
---
The desktop runtime opens a native window through the host layer, so runtime commands are intended for a local graphical environment.
## 📜 License
## Validation
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
Format, lint, and test the workspace:
---
```bash
make test
```
## ✨ Final Note
Run the CI-oriented non-domain gate:
PROMETEU is both a technical and pedagogical project. The idea is not to hide complexity, but to **expose the right complexity**, at the right level, so it can be understood, studied, and explored.
```bash
make ci
```
Run the CI-oriented domain coverage gate:
```bash
make ci-domains
```
The debugger socket tests bind localhost sockets and are marked ignored in Rust test metadata; `make test` includes them through the dedicated `test-debugger-socket` target.
## Coverage Governance
The repository uses a two-layer runtime-edge coverage model:
- a mandatory CI gate based on canonical runtime-edge domains;
- domain-oriented review evidence for the canonical runtime-edge domains.
Current canonical domains:
- `system/runtime`
- `fs`
- `asset/bank`
- `firmware`
- `host-dependent`
Global coverage artifacts:
```bash
make ci
make cobertura
make ci-domains
```
This produces:
- `target/llvm-cov/html`
- `target/llvm-cov/report.json`
- `target/llvm-cov/summary.json`
- `target/llvm-cov/cobertura.xml`
Domain-oriented evidence helpers:
```bash
make coverage-domain-list
make coverage-domain-evidence DOMAIN=firmware
make coverage-domain-evidence DOMAIN=host-dependent
```
Per-domain baselines currently start around `60`.
That does not waive test obligations. It means the project is adopting explicit domain evidence immediately while tightening quantitative expectations incrementally over time.
`make ci-domains` is the CI entrypoint used by Jenkins.
It runs the standard coverage pipeline, produces the HTML/XML/JSON artifacts, and fails when any canonical domain falls below its configured `baseline_percent`.
The CI gate currently compares domain `lines` coverage against each domain baseline.
When a PR changes the observable contract of a canonical domain, it is expected to:
- add or adjust tests for that domain; or
- justify explicitly why the observable contract did not change.
Improving the global percentage alone is not sufficient review evidence when the changed behavior belongs to a canonical domain.
## Current State
The project is still in active architectural and implementation convergence.
- the machine contract is being clarified through the specs and discussion workflow;
- the workspace already contains concrete runtime and host code;
- the CLI dispatcher currently exposes the runtime paths that are backed by binaries in this workspace.
Treat APIs, file formats, and execution flows as evolving unless the relevant spec explicitly defines them as stable.
## License
This project is licensed under the [MIT License](LICENSE).

17
build/program.disasm.txt Normal file
View File

@ -0,0 +1,17 @@
00000000 Call U32(18) U32(0)
0000000A FrameSync
0000000C Jmp U32(0)
00000012 PushI32 U32(1) ; test_supported.ts:2
00000018 SetLocal U32(0) ; test_supported.ts:2
0000001E GetLocal U32(0) ; test_supported.ts:3
00000024 PushI32 U32(10) ; test_supported.ts:3
0000002A Lt ; test_supported.ts:3
0000002C JmpIfFalse U32(80) ; test_supported.ts:3
00000032 GetLocal U32(0) ; test_supported.ts:4
00000038 PushI32 U32(1) ; test_supported.ts:4
0000003E Add ; test_supported.ts:4
00000040 Dup ; test_supported.ts:4
00000042 SetLocal U32(0) ; test_supported.ts:4
00000048 Pop ; test_supported.ts:4
0000004A Jmp U32(80)
00000050 Ret

BIN
build/program.pbc Normal file

Binary file not shown.

74
build/symbols.json Normal file
View File

@ -0,0 +1,74 @@
[
{
"pc": 18,
"file": "test_supported.ts",
"line": 2,
"col": 13
},
{
"pc": 24,
"file": "test_supported.ts",
"line": 2,
"col": 9
},
{
"pc": 30,
"file": "test_supported.ts",
"line": 3,
"col": 9
},
{
"pc": 36,
"file": "test_supported.ts",
"line": 3,
"col": 13
},
{
"pc": 42,
"file": "test_supported.ts",
"line": 3,
"col": 9
},
{
"pc": 44,
"file": "test_supported.ts",
"line": 3,
"col": 5
},
{
"pc": 50,
"file": "test_supported.ts",
"line": 4,
"col": 13
},
{
"pc": 56,
"file": "test_supported.ts",
"line": 4,
"col": 17
},
{
"pc": 62,
"file": "test_supported.ts",
"line": 4,
"col": 13
},
{
"pc": 64,
"file": "test_supported.ts",
"line": 4,
"col": 9
},
{
"pc": 66,
"file": "test_supported.ts",
"line": 4,
"col": 9
},
{
"pc": 72,
"file": "test_supported.ts",
"line": 4,
"col": 9
}
]

View File

@ -1,50 +0,0 @@
//! This module defines the Application Binary Interface (ABI) of the Prometeu Virtual Machine.
//! It specifies how instructions are encoded in bytes and how they interact with memory.
/// Attempted to execute an unknown or invalid opcode.
pub const TRAP_ILLEGAL_INSTRUCTION: u32 = 0x0000_0001;
/// Program explicitly requested termination via the TRAP opcode.
pub const TRAP_EXPLICIT: u32 = 0x0000_0002;
/// Out-of-bounds access (e.g., stack/heap/local index out of range).
pub const TRAP_OOB: u32 = 0x0000_0003;
/// Type mismatch for the attempted operation (e.g., wrong operand type or syscall argument type).
pub const TRAP_TYPE: u32 = 0x0000_0004;
/// The syscall ID provided is not recognized by the system.
pub const TRAP_INVALID_SYSCALL: u32 = 0x0000_0007;
/// Not enough values on the operand stack for the requested operation/syscall.
pub const TRAP_STACK_UNDERFLOW: u32 = 0x0000_0008;
/// Attempted to access a local slot that is out of bounds for the current frame.
pub const TRAP_INVALID_LOCAL: u32 = 0x0000_0009;
/// Division or modulo by zero.
pub const TRAP_DIV_ZERO: u32 = 0x0000_000A;
/// Attempted to call a function that does not exist in the function table.
pub const TRAP_INVALID_FUNC: u32 = 0x0000_000B;
/// Executed RET with an incorrect stack height (mismatch with function metadata).
pub const TRAP_BAD_RET_SLOTS: u32 = 0x0000_000C;
/// The intrinsic ID provided is not recognized by the runtime, or its metadata is invalid.
pub const TRAP_INVALID_INTRINSIC: u32 = 0x0000_000D;
use serde::{Deserialize, Serialize};
/// Detailed information about a source code span.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SourceSpan {
pub file_id: u32,
pub start: u32,
pub end: u32,
}
/// Detailed information about a runtime trap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrapInfo {
/// The specific trap code (e.g., TRAP_OOB).
pub code: u32,
/// The numeric value of the opcode that triggered the trap.
pub opcode: u16,
/// A human-readable message explaining the trap.
pub message: String,
/// The absolute Program Counter (PC) address where the trap occurred.
pub pc: u32,
/// Optional source span information if debug symbols are available.
pub span: Option<SourceSpan>,
}

View File

@ -1,367 +0,0 @@
//! Minimal deterministic assembler for the canonical disassembly format.
//!
//! This is intended primarily for roundtrip tests: `bytes -> disassemble -> assemble -> bytes`.
//! It supports all mnemonics emitted by `disassembler.rs` and their operand formats.
use crate::isa::core::CoreOpCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AsmError {
EmptyLine,
UnknownMnemonic(String),
UnexpectedOperand(String),
MissingOperand(String),
InvalidOperand(String),
}
fn emit_u16(v: u16, out: &mut Vec<u8>) {
out.extend_from_slice(&v.to_le_bytes());
}
fn emit_u32(v: u32, out: &mut Vec<u8>) {
out.extend_from_slice(&v.to_le_bytes());
}
fn emit_i32(v: i32, out: &mut Vec<u8>) {
out.extend_from_slice(&v.to_le_bytes());
}
fn emit_i64(v: i64, out: &mut Vec<u8>) {
out.extend_from_slice(&v.to_le_bytes());
}
fn emit_f64_bits(bits: u64, out: &mut Vec<u8>) {
out.extend_from_slice(&bits.to_le_bytes());
}
fn parse_u32_any(s: &str) -> Result<u32, AsmError> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("0x") {
u32::from_str_radix(rest, 16).map_err(|_| AsmError::InvalidOperand(s.into()))
} else {
s.parse::<u32>().map_err(|_| AsmError::InvalidOperand(s.into()))
}
}
fn parse_i32_any(s: &str) -> Result<i32, AsmError> {
s.trim().parse::<i32>().map_err(|_| AsmError::InvalidOperand(s.into()))
}
fn parse_i64_any(s: &str) -> Result<i64, AsmError> {
s.trim().parse::<i64>().map_err(|_| AsmError::InvalidOperand(s.into()))
}
fn parse_f64_bits(s: &str) -> Result<u64, AsmError> {
let s = s.trim();
let s = s.strip_prefix("f64:").ok_or_else(|| AsmError::InvalidOperand(s.into()))?;
let hex = s.strip_prefix("0x").ok_or_else(|| AsmError::InvalidOperand(s.into()))?;
if hex.len() != 16 {
return Err(AsmError::InvalidOperand(s.into()));
}
u64::from_str_radix(hex, 16).map_err(|_| AsmError::InvalidOperand(s.into()))
}
fn parse_keyvals(s: &str) -> Result<(&str, &str), AsmError> {
// Parses formats like: "fn=123, captures=2" or "fn=3, argc=1"
let mut parts = s.split(',');
let a = parts.next().ok_or_else(|| AsmError::MissingOperand(s.into()))?.trim();
let b = parts.next().ok_or_else(|| AsmError::MissingOperand(s.into()))?.trim();
if parts.next().is_some() {
return Err(AsmError::InvalidOperand(s.into()));
}
Ok((a, b))
}
fn parse_pair<'a>(a: &'a str, ka: &str, b: &'a str, kb: &str) -> Result<(u32, u32), AsmError> {
let (ka_l, va_s) = a.split_once('=').ok_or_else(|| AsmError::InvalidOperand(a.into()))?;
let (kb_l, vb_s) = b.split_once('=').ok_or_else(|| AsmError::InvalidOperand(b.into()))?;
if ka_l.trim() != ka || kb_l.trim() != kb {
return Err(AsmError::InvalidOperand(format!("expected keys {} and {}", ka, kb)));
}
let va = parse_u32_any(va_s)?;
let vb = parse_u32_any(vb_s)?;
Ok((va, vb))
}
fn parse_mnemonic(line: &str) -> (&str, &str) {
let line = line.trim();
if let Some(sp) = line.find(char::is_whitespace) {
let (mn, rest) = line.split_at(sp);
(mn, rest.trim())
} else {
(line, "")
}
}
pub fn assemble(src: &str) -> Result<Vec<u8>, AsmError> {
let mut out = Vec::new();
for raw_line in src.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let (mn, ops) = parse_mnemonic(line);
match mn {
// Zero-operand
"NOP" => {
emit_u16(CoreOpCode::Nop as u16, &mut out);
}
"HALT" => {
emit_u16(CoreOpCode::Halt as u16, &mut out);
}
"TRAP" => {
emit_u16(CoreOpCode::Trap as u16, &mut out);
}
"DUP" => {
emit_u16(CoreOpCode::Dup as u16, &mut out);
}
"SWAP" => {
emit_u16(CoreOpCode::Swap as u16, &mut out);
}
"ADD" => {
emit_u16(CoreOpCode::Add as u16, &mut out);
}
"SUB" => {
emit_u16(CoreOpCode::Sub as u16, &mut out);
}
"MUL" => {
emit_u16(CoreOpCode::Mul as u16, &mut out);
}
"DIV" => {
emit_u16(CoreOpCode::Div as u16, &mut out);
}
"MOD" => {
emit_u16(CoreOpCode::Mod as u16, &mut out);
}
"NEG" => {
emit_u16(CoreOpCode::Neg as u16, &mut out);
}
"EQ" => {
emit_u16(CoreOpCode::Eq as u16, &mut out);
}
"NEQ" => {
emit_u16(CoreOpCode::Neq as u16, &mut out);
}
"LT" => {
emit_u16(CoreOpCode::Lt as u16, &mut out);
}
"LTE" => {
emit_u16(CoreOpCode::Lte as u16, &mut out);
}
"GT" => {
emit_u16(CoreOpCode::Gt as u16, &mut out);
}
"GTE" => {
emit_u16(CoreOpCode::Gte as u16, &mut out);
}
"AND" => {
emit_u16(CoreOpCode::And as u16, &mut out);
}
"OR" => {
emit_u16(CoreOpCode::Or as u16, &mut out);
}
"NOT" => {
emit_u16(CoreOpCode::Not as u16, &mut out);
}
"BIT_AND" => {
emit_u16(CoreOpCode::BitAnd as u16, &mut out);
}
"BIT_OR" => {
emit_u16(CoreOpCode::BitOr as u16, &mut out);
}
"BIT_XOR" => {
emit_u16(CoreOpCode::BitXor as u16, &mut out);
}
"SHL" => {
emit_u16(CoreOpCode::Shl as u16, &mut out);
}
"SHR" => {
emit_u16(CoreOpCode::Shr as u16, &mut out);
}
"RET" => {
emit_u16(CoreOpCode::Ret as u16, &mut out);
}
"YIELD" => {
emit_u16(CoreOpCode::Yield as u16, &mut out);
}
"FRAME_SYNC" => {
emit_u16(CoreOpCode::FrameSync as u16, &mut out);
}
// One u32 immediate (decimal or hex accepted; SYSCALL/HOSTCALL commonly use hex/idx)
"JMP" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Jmp as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"JMP_IF_FALSE" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::JmpIfFalse as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"JMP_IF_TRUE" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::JmpIfTrue as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"PUSH_CONST" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::PushConst as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"PUSH_I64" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::PushI64 as u16, &mut out);
emit_i64(parse_i64_any(ops)?, &mut out);
}
"PUSH_F64" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::PushF64 as u16, &mut out);
emit_f64_bits(parse_f64_bits(ops)?, &mut out);
}
"PUSH_BOOL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
let v = parse_u32_any(ops)? as u8;
emit_u16(CoreOpCode::PushBool as u16, &mut out);
out.push(v);
}
"PUSH_I32" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::PushI32 as u16, &mut out);
emit_i32(parse_i32_any(ops)?, &mut out);
}
"POP_N" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::PopN as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"GET_GLOBAL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::GetGlobal as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"SET_GLOBAL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::SetGlobal as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"GET_LOCAL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::GetLocal as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"SET_LOCAL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::SetLocal as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"CALL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Call as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"CALL_CLOSURE" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
let (k, v) =
ops.split_once('=').ok_or_else(|| AsmError::InvalidOperand(ops.into()))?;
if k.trim() != "argc" {
return Err(AsmError::InvalidOperand(ops.into()));
}
emit_u16(CoreOpCode::CallClosure as u16, &mut out);
emit_u32(parse_u32_any(v)?, &mut out);
}
"MAKE_CLOSURE" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
let (a, b) = parse_keyvals(ops)?;
// Accept either order but require exact key names
let (fn_id, captures) = if a.starts_with("fn=") && b.starts_with("captures=") {
parse_pair(a, "fn", b, "captures")?
} else if a.starts_with("captures=") && b.starts_with("fn=") {
let (cap, fid) = parse_pair(a, "captures", b, "fn")?;
(fid, cap)
} else {
return Err(AsmError::InvalidOperand(ops.into()));
};
emit_u16(CoreOpCode::MakeClosure as u16, &mut out);
emit_u32(fn_id, &mut out);
emit_u32(captures, &mut out);
}
"SPAWN" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
let (a, b) = parse_keyvals(ops)?;
let (fn_id, argc) = if a.starts_with("fn=") && b.starts_with("argc=") {
parse_pair(a, "fn", b, "argc")?
} else if a.starts_with("argc=") && b.starts_with("fn=") {
let (ac, fid) = parse_pair(a, "argc", b, "fn")?;
(fid, ac)
} else {
return Err(AsmError::InvalidOperand(ops.into()));
};
emit_u16(CoreOpCode::Spawn as u16, &mut out);
emit_u32(fn_id, &mut out);
emit_u32(argc, &mut out);
}
"SLEEP" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Sleep as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"HOSTCALL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Hostcall as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"SYSCALL" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Syscall as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
"INTRINSIC" => {
if ops.is_empty() {
return Err(AsmError::MissingOperand(line.into()));
}
emit_u16(CoreOpCode::Intrinsic as u16, &mut out);
emit_u32(parse_u32_any(ops)?, &mut out);
}
other => return Err(AsmError::UnknownMnemonic(other.into())),
}
}
Ok(out)
}

View File

@ -1,133 +0,0 @@
//! Canonical bytecode decoder for Prometeu Bytecode (PBX payload).
//!
//! Single source of truth for instruction decoding used by compiler/linker/verifier/VM.
//!
//! Contract:
//! - Instructions are encoded as: [opcode: u16 LE][immediate: spec.imm_bytes]
//! - `decode_next(pc, bytes)` returns a typed `DecodedInstr` with canonical `next_pc`.
//! - Immediate helpers validate sizes deterministically and return explicit errors.
use crate::isa::core::{CoreOpCode, CoreOpCodeSpecExt, CoreOpcodeSpec};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
TruncatedOpcode { pc: usize },
UnknownOpcode { pc: usize, opcode: u16 },
TruncatedImmediate { pc: usize, opcode: CoreOpCode, need: usize, have: usize },
ImmediateSizeMismatch { pc: usize, opcode: CoreOpCode, expected: usize, actual: usize },
}
#[derive(Debug, Clone, Copy)]
pub struct DecodedInstr<'a> {
pub opcode: CoreOpCode,
pub pc: usize,
pub next_pc: usize,
/// Raw immediate bytes slice, guaranteed to have length `opcode.spec().imm_bytes`.
pub imm: &'a [u8],
}
impl<'a> DecodedInstr<'a> {
#[inline]
fn ensure_len(&self, expected: usize) -> Result<(), DecodeError> {
if self.imm.len() != expected {
return Err(DecodeError::ImmediateSizeMismatch {
pc: self.pc,
opcode: self.opcode,
expected,
actual: self.imm.len(),
});
}
Ok(())
}
#[inline]
pub fn imm_u8(&self) -> Result<u8, DecodeError> {
self.ensure_len(1)?;
Ok(self.imm[0])
}
#[inline]
pub fn imm_u16(&self) -> Result<u16, DecodeError> {
self.ensure_len(2)?;
Ok(u16::from_le_bytes(self.imm.try_into().unwrap()))
}
#[inline]
pub fn imm_u32(&self) -> Result<u32, DecodeError> {
self.ensure_len(4)?;
Ok(u32::from_le_bytes(self.imm.try_into().unwrap()))
}
#[inline]
pub fn imm_i32(&self) -> Result<i32, DecodeError> {
self.ensure_len(4)?;
Ok(i32::from_le_bytes(self.imm.try_into().unwrap()))
}
#[inline]
pub fn imm_i64(&self) -> Result<i64, DecodeError> {
self.ensure_len(8)?;
Ok(i64::from_le_bytes(self.imm.try_into().unwrap()))
}
#[inline]
pub fn imm_f64(&self) -> Result<f64, DecodeError> {
self.ensure_len(8)?;
Ok(f64::from_le_bytes(self.imm.try_into().unwrap()))
}
/// Helper for opcodes carrying two u32 values packed in 8 bytes (e.g., ALLOC meta).
#[inline]
pub fn imm_u32x2(&self) -> Result<(u32, u32), DecodeError> {
self.ensure_len(8)?;
let a = u32::from_le_bytes(self.imm[0..4].try_into().unwrap());
let b = u32::from_le_bytes(self.imm[4..8].try_into().unwrap());
Ok((a, b))
}
}
/// Decodes the instruction at program counter `pc` from `bytes`.
/// Returns the decoded instruction with canonical `next_pc`.
#[inline]
pub fn decode_next(pc: usize, bytes: &'_ [u8]) -> Result<DecodedInstr<'_>, DecodeError> {
if pc + 2 > bytes.len() {
return Err(DecodeError::TruncatedOpcode { pc });
}
let opcode_val = u16::from_le_bytes([bytes[pc], bytes[pc + 1]]);
let opcode = CoreOpCode::try_from(opcode_val)
.map_err(|_| DecodeError::UnknownOpcode { pc, opcode: opcode_val })?;
let spec: CoreOpcodeSpec = opcode.spec();
let imm_start = pc + 2;
let imm_end = imm_start + (spec.imm_bytes as usize);
if imm_end > bytes.len() {
return Err(DecodeError::TruncatedImmediate {
pc,
opcode,
need: spec.imm_bytes as usize,
have: bytes.len().saturating_sub(imm_start),
});
}
Ok(DecodedInstr { opcode, pc, next_pc: imm_end, imm: &bytes[imm_start..imm_end] })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_opcode_is_reported_deterministically() {
// 0x0060 was previously a legacy opcode; now it must be unknown.
let bytes = vec![0x60, 0x00]; // little-endian u16 = 0x0060
match decode_next(0, &bytes) {
Err(DecodeError::UnknownOpcode { pc, opcode }) => {
assert_eq!(pc, 0);
assert_eq!(opcode, 0x0060);
}
other => panic!("expected UnknownOpcode, got {:?}", other),
}
}
}

View File

@ -1,120 +0,0 @@
//! Deterministic disassembler for Prometeu Bytecode (PBX payload).
//!
//! Goals:
//! - Stable formatting across platforms (snapshot-friendly).
//! - Complete coverage of the Core ISA, including closures/coroutines.
//! - Roundtrip-safe with the paired `assembler` module.
//!
//! Format (one instruction per line):
//! - `MNEMONIC` for zero-operand instructions.
//! - `MNEMONIC <imm>` for 1-operand instructions (decimal unless stated).
//! - Special operand formats:
//! - `PUSH_F64 f64:0xhhhhhhhhhhhhhhhh` — exact IEEE-754 bits in hex (little-endian to_bits()).
//! - `MAKE_CLOSURE fn=<u32>, captures=<u32>`
//! - `SPAWN fn=<u32>, argc=<u32>`
//! - `CALL_CLOSURE argc=<u32>`
//! - `HOSTCALL <index>` is printed in decimal because it is a `SYSC` table index.
//! - `SYSCALL` is printed as `SYSCALL 0xhhhh` (numeric id in hex) to avoid cross-crate deps.
//!
//! Notes:
//! - All integers are printed in base-10 except where explicitly noted.
//! - Floats use exact bit-pattern format to prevent locale/rounding differences.
//! - Ordering is the canonical decode order; no address prefixes are emitted.
use crate::decode_next;
use crate::isa::core::{CoreOpCode, CoreOpCodeSpecExt};
use crate::DecodeError;
fn fmt_f64_bits(bits: u64) -> String {
// Fixed-width 16 hex digits, lowercase.
format!("f64:0x{bits:016x}")
}
fn format_operand(op: CoreOpCode, imm: &[u8]) -> String {
match op {
CoreOpCode::Jmp | CoreOpCode::JmpIfFalse | CoreOpCode::JmpIfTrue => {
let v = u32::from_le_bytes(imm.try_into().unwrap());
format!("{}", v)
}
CoreOpCode::PushI64 => {
let v = i64::from_le_bytes(imm.try_into().unwrap());
format!("{}", v)
}
CoreOpCode::PushF64 => {
let v = u64::from_le_bytes(imm.try_into().unwrap());
fmt_f64_bits(v)
}
CoreOpCode::PushBool => {
let v = imm[0];
format!("{}", v)
}
CoreOpCode::PushI32 => {
let v = i32::from_le_bytes(imm.try_into().unwrap());
format!("{}", v)
}
CoreOpCode::PopN
| CoreOpCode::PushConst
| CoreOpCode::GetGlobal
| CoreOpCode::SetGlobal
| CoreOpCode::GetLocal
| CoreOpCode::SetLocal
| CoreOpCode::Call
| CoreOpCode::Sleep
| CoreOpCode::Hostcall => {
let v = u32::from_le_bytes(imm.try_into().unwrap());
format!("{}", v)
}
CoreOpCode::MakeClosure => {
let fn_id = u32::from_le_bytes(imm[0..4].try_into().unwrap());
let cap = u32::from_le_bytes(imm[4..8].try_into().unwrap());
format!("fn={}, captures={}", fn_id, cap)
}
CoreOpCode::CallClosure => {
let argc = u32::from_le_bytes(imm.try_into().unwrap());
format!("argc={}", argc)
}
CoreOpCode::Spawn => {
let fn_id = u32::from_le_bytes(imm[0..4].try_into().unwrap());
let argc = u32::from_le_bytes(imm[4..8].try_into().unwrap());
format!("fn={}, argc={}", fn_id, argc)
}
CoreOpCode::Syscall => {
let id = u32::from_le_bytes(imm.try_into().unwrap());
// Hex id stable, avoids dependency on HAL metadata.
format!("0x{:04x}", id)
}
CoreOpCode::Intrinsic => {
let id = u32::from_le_bytes(imm.try_into().unwrap());
format!("0x{:04x}", id)
}
_ => {
// Fallback: raw immediate hex (little-endian, as encoded)
let mut s = String::with_capacity(2 + imm.len() * 2);
s.push_str("0x");
for b in imm {
use core::fmt::Write as _;
let _ = write!(&mut s, "{:02x}", b);
}
s
}
}
}
/// Disassembles a contiguous byte slice (single function body) into deterministic text.
pub fn disassemble(bytes: &[u8]) -> Result<String, DecodeError> {
let mut pc = 0usize;
let mut out = Vec::new();
while pc < bytes.len() {
let instr = decode_next(pc, bytes)?;
let name = instr.opcode.spec().name;
let imm_len = instr.opcode.spec().imm_bytes as usize;
if imm_len == 0 {
out.push(name.to_string());
} else {
let ops = format_operand(instr.opcode, instr.imm);
out.push(format!("{} {}", name, ops));
}
pc = instr.next_pc;
}
Ok(out.join("\n"))
}

View File

@ -1,12 +0,0 @@
//! Core ISA profile (bytecode-level, stable, canonical).
//!
//! This profile is the canonical opcode surface used by decoder/disasm and
//! mirrors the runtime-visible instruction set implemented today, including
//! closures, coroutines, intrinsics, and hostcall patching semantics.
// For PR-1.4 we define the core profile as the current `OpCode` set. We re-export
// the opcode type and spec so downstream tools can import from a stable path:
// `prometeu_bytecode::isa::core::*`.
pub use crate::opcode::OpCode as CoreOpCode;
pub use crate::opcode_spec::{OpCodeSpecExt as CoreOpCodeSpecExt, OpcodeSpec as CoreOpcodeSpec};

View File

@ -1,6 +0,0 @@
//! ISA module tree
//!
//! This module defines stable ISA profiles. For PR-1.4 we expose the
//! minimal core ISA surface used by the encoder/decoder and (later) verifier.
pub mod core;

View File

@ -1,98 +0,0 @@
//! Shared bytecode layout utilities, used by both compiler (emitter/linker)
//! and the VM (verifier/loader). This ensures a single source of truth for
//! how function ranges, instruction boundaries, and pc→function lookups are
//! interpreted post-link.
use crate::model::FunctionMeta;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FunctionLayout {
pub start: usize,
pub end: usize, // exclusive
}
/// Precompute canonical [start, end) ranges for all functions.
///
/// Contract:
/// - Ranges are computed by sorting functions by `code_offset` (stable),
/// then using the next function's start as the current end; the last
/// function ends at `code_len_total`.
/// - The returned vector is indexed by the original function indices.
pub fn compute_function_layouts(
functions: &[FunctionMeta],
code_len_total: usize,
) -> Vec<FunctionLayout> {
// Build index array and sort by start offset (stable to preserve relative order).
let mut idxs: Vec<usize> = (0..functions.len()).collect();
idxs.sort_by_key(|&i| functions[i].code_offset as usize);
// Optional guard: offsets should be strictly increasing (duplicates are suspicious).
for w in idxs.windows(2) {
if let [a, b] = *w {
let sa = functions[a].code_offset as usize;
let sb = functions[b].code_offset as usize;
debug_assert!(
sa < sb,
"Function code_offset must be strictly increasing: {} vs {} (indices {} and {})",
sa,
sb,
a,
b
);
}
}
let mut out = vec![FunctionLayout { start: 0, end: 0 }; functions.len()];
for (pos, &i) in idxs.iter().enumerate() {
let start = functions[i].code_offset as usize;
let end = if pos + 1 < idxs.len() {
functions[idxs[pos + 1]].code_offset as usize
} else {
code_len_total
};
out[i] = FunctionLayout { start, end };
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn build_funcs(offsets: &[usize], lens: Option<&[usize]>) -> Vec<FunctionMeta> {
let mut v = Vec::new();
for (i, off) in offsets.iter().copied().enumerate() {
let len_u32 = lens.and_then(|ls| ls.get(i).copied()).unwrap_or(0) as u32;
v.push(FunctionMeta {
code_offset: off as u32,
code_len: len_u32,
param_slots: 0,
local_slots: 0,
return_slots: 0,
max_stack_slots: 0,
});
}
v
}
#[test]
fn compute_function_layouts_end_is_next_start() {
// Synthetic functions with known offsets: 0, 10, 25; total_len = 40
let funcs = build_funcs(&[0, 10, 25], None);
let layouts = compute_function_layouts(&funcs, 40);
assert_eq!(layouts.len(), 3);
assert_eq!(layouts[0], FunctionLayout { start: 0, end: 10 });
assert_eq!(layouts[1], FunctionLayout { start: 10, end: 25 });
assert_eq!(layouts[2], FunctionLayout { start: 25, end: 40 });
for i in 0..3 {
let l = &layouts[i];
assert_eq!(
l.end - l.start,
(funcs.get(i + 1).map(|n| n.code_offset as usize).unwrap_or(40))
- (funcs[i].code_offset as usize)
);
}
}
}

View File

@ -1,24 +0,0 @@
mod abi;
pub mod assembler;
mod decoder;
mod disassembler;
pub mod isa; // canonical ISA boundary (core and future profiles)
mod layout;
pub mod model;
mod opcode;
mod opcode_spec;
mod program_image;
mod value;
pub use abi::{
TrapInfo, TRAP_BAD_RET_SLOTS, TRAP_DIV_ZERO, TRAP_EXPLICIT, TRAP_ILLEGAL_INSTRUCTION,
TRAP_INVALID_FUNC, TRAP_INVALID_INTRINSIC, TRAP_INVALID_LOCAL, TRAP_INVALID_SYSCALL, TRAP_OOB,
TRAP_STACK_UNDERFLOW, TRAP_TYPE,
};
pub use assembler::{assemble, AsmError};
pub use decoder::{decode_next, DecodeError};
pub use disassembler::disassemble;
pub use layout::{compute_function_layouts, FunctionLayout};
pub use model::{BytecodeLoader, FunctionMeta, LoadError, SyscallDecl};
pub use program_image::ProgramImage;
pub use value::{string_materialization_count, HeapRef, Value};

View File

@ -1,946 +0,0 @@
use crate::abi::SourceSpan;
use crate::opcode::OpCode;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// An entry in the Constant Pool.
///
/// The Constant Pool is a table of unique values used by the program.
/// Instead of embedding large data (like strings) directly in the instruction stream,
/// the bytecode uses `PushConst <index>` to load these values onto the stack.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ConstantPoolEntry {
/// Reserved index (0). Represents a null/undefined value.
Null,
/// A 64-bit integer constant.
Int64(i64),
/// A 64-bit floating point constant.
Float64(f64),
/// A boolean constant.
Boolean(bool),
/// A UTF-8 string constant.
String(String),
/// A 32-bit integer constant.
Int32(i32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
InvalidMagic,
InvalidVersion,
InvalidEndianness,
OverlappingSections,
SectionOutOfBounds,
InvalidOpcode,
InvalidConstIndex,
InvalidFunctionIndex,
MalformedHeader,
MalformedSection,
MissingSyscallSection,
DuplicateSyscallIdentity,
InvalidUtf8,
UnexpectedEof,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FunctionMeta {
pub code_offset: u32,
pub code_len: u32,
pub param_slots: u16,
pub local_slots: u16,
pub return_slots: u16,
pub max_stack_slots: u16,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugInfo {
pub pc_to_span: Vec<(u32, SourceSpan)>, // Sorted by PC
pub function_names: Vec<(u32, String)>, // (func_idx, name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Export {
pub symbol: String,
pub func_idx: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyscallDecl {
pub module: String,
pub name: String,
pub version: u16,
pub arg_slots: u16,
pub ret_slots: u16,
}
const SECTION_KIND_CONST_POOL: u32 = 0;
const SECTION_KIND_FUNCTIONS: u32 = 1;
const SECTION_KIND_CODE: u32 = 2;
const SECTION_KIND_DEBUG: u32 = 3;
const SECTION_KIND_EXPORTS: u32 = 4;
const SECTION_KIND_SYSCALLS: u32 = 5;
/// Represents the final serialized format of a PBS v0 module.
///
/// This structure is a pure data container for the PBS format. It does NOT
/// contain any linker-like logic (symbol resolution, patching, etc.).
/// All multi-module programs must be flattened and linked by the compiler
/// before being serialized into this format.
#[derive(Debug, Clone, PartialEq)]
pub struct BytecodeModule {
pub version: u16,
pub const_pool: Vec<ConstantPoolEntry>,
pub functions: Vec<FunctionMeta>,
pub code: Vec<u8>,
pub debug_info: Option<DebugInfo>,
pub exports: Vec<Export>,
pub syscalls: Vec<SyscallDecl>,
}
impl BytecodeModule {
pub fn serialize(&self) -> Vec<u8> {
let cp_data = self.serialize_const_pool();
let func_data = self.serialize_functions();
let code_data = self.code.clone();
let debug_data =
self.debug_info.as_ref().map(|di| self.serialize_debug(di)).unwrap_or_default();
let export_data = self.serialize_exports();
let syscall_data = self.serialize_syscalls();
let mut final_sections = Vec::new();
if !cp_data.is_empty() {
final_sections.push((SECTION_KIND_CONST_POOL, cp_data));
}
if !func_data.is_empty() {
final_sections.push((SECTION_KIND_FUNCTIONS, func_data));
}
if !code_data.is_empty() {
final_sections.push((SECTION_KIND_CODE, code_data));
}
if !debug_data.is_empty() {
final_sections.push((SECTION_KIND_DEBUG, debug_data));
}
if !export_data.is_empty() {
final_sections.push((SECTION_KIND_EXPORTS, export_data));
}
final_sections.push((SECTION_KIND_SYSCALLS, syscall_data));
let mut out = Vec::new();
// Magic "PBS\0"
out.extend_from_slice(b"PBS\0");
// Version 0
out.extend_from_slice(&0u16.to_le_bytes());
// Endianness 0 (Little Endian), Reserved
out.extend_from_slice(&[0u8, 0u8]);
// section_count
out.extend_from_slice(&(final_sections.len() as u32).to_le_bytes());
// padding to 32 bytes
out.extend_from_slice(&[0u8; 20]);
let mut current_offset = 32 + (final_sections.len() as u32 * 12);
// Write section table
for (kind, data) in &final_sections {
let k: u32 = *kind;
out.extend_from_slice(&k.to_le_bytes());
out.extend_from_slice(&current_offset.to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
current_offset += data.len() as u32;
}
// Write section data
for (_, data) in final_sections {
out.extend_from_slice(&data);
}
out
}
fn serialize_const_pool(&self) -> Vec<u8> {
if self.const_pool.is_empty() {
return Vec::new();
}
let mut data = Vec::new();
data.extend_from_slice(&(self.const_pool.len() as u32).to_le_bytes());
for entry in &self.const_pool {
match entry {
ConstantPoolEntry::Null => data.push(0),
ConstantPoolEntry::Int64(v) => {
data.push(1);
data.extend_from_slice(&v.to_le_bytes());
}
ConstantPoolEntry::Float64(v) => {
data.push(2);
data.extend_from_slice(&v.to_le_bytes());
}
ConstantPoolEntry::Boolean(v) => {
data.push(3);
data.push(if *v { 1 } else { 0 });
}
ConstantPoolEntry::String(v) => {
data.push(4);
let s_bytes = v.as_bytes();
data.extend_from_slice(&(s_bytes.len() as u32).to_le_bytes());
data.extend_from_slice(s_bytes);
}
ConstantPoolEntry::Int32(v) => {
data.push(5);
data.extend_from_slice(&v.to_le_bytes());
}
}
}
data
}
fn serialize_functions(&self) -> Vec<u8> {
if self.functions.is_empty() {
return Vec::new();
}
let mut data = Vec::new();
data.extend_from_slice(&(self.functions.len() as u32).to_le_bytes());
for f in &self.functions {
data.extend_from_slice(&f.code_offset.to_le_bytes());
data.extend_from_slice(&f.code_len.to_le_bytes());
data.extend_from_slice(&f.param_slots.to_le_bytes());
data.extend_from_slice(&f.local_slots.to_le_bytes());
data.extend_from_slice(&f.return_slots.to_le_bytes());
data.extend_from_slice(&f.max_stack_slots.to_le_bytes());
}
data
}
fn serialize_debug(&self, di: &DebugInfo) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&(di.pc_to_span.len() as u32).to_le_bytes());
for (pc, span) in &di.pc_to_span {
data.extend_from_slice(&pc.to_le_bytes());
data.extend_from_slice(&span.file_id.to_le_bytes());
data.extend_from_slice(&span.start.to_le_bytes());
data.extend_from_slice(&span.end.to_le_bytes());
}
data.extend_from_slice(&(di.function_names.len() as u32).to_le_bytes());
for (idx, name) in &di.function_names {
data.extend_from_slice(&idx.to_le_bytes());
let n_bytes = name.as_bytes();
data.extend_from_slice(&(n_bytes.len() as u32).to_le_bytes());
data.extend_from_slice(n_bytes);
}
data
}
fn serialize_exports(&self) -> Vec<u8> {
if self.exports.is_empty() {
return Vec::new();
}
let mut data = Vec::new();
data.extend_from_slice(&(self.exports.len() as u32).to_le_bytes());
for exp in &self.exports {
data.extend_from_slice(&exp.func_idx.to_le_bytes());
let s_bytes = exp.symbol.as_bytes();
data.extend_from_slice(&(s_bytes.len() as u32).to_le_bytes());
data.extend_from_slice(s_bytes);
}
data
}
fn serialize_syscalls(&self) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&(self.syscalls.len() as u32).to_le_bytes());
for syscall in &self.syscalls {
let module = syscall.module.as_bytes();
let name = syscall.name.as_bytes();
assert!(u16::try_from(module.len()).is_ok(), "SYSC module name exceeds u16 length");
assert!(u16::try_from(name.len()).is_ok(), "SYSC syscall name exceeds u16 length");
data.extend_from_slice(&(module.len() as u16).to_le_bytes());
data.extend_from_slice(module);
data.extend_from_slice(&(name.len() as u16).to_le_bytes());
data.extend_from_slice(name);
data.extend_from_slice(&syscall.version.to_le_bytes());
data.extend_from_slice(&syscall.arg_slots.to_le_bytes());
data.extend_from_slice(&syscall.ret_slots.to_le_bytes());
}
data
}
}
pub struct BytecodeLoader;
impl BytecodeLoader {
pub fn load(bytes: &[u8]) -> Result<BytecodeModule, LoadError> {
if bytes.len() < 32 {
return Err(LoadError::UnexpectedEof);
}
// Magic "PBS\0"
if &bytes[0..4] != b"PBS\0" {
return Err(LoadError::InvalidMagic);
}
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
if version != 0 {
return Err(LoadError::InvalidVersion);
}
let endianness = bytes[6];
if endianness != 0 {
// 0 = Little Endian
return Err(LoadError::InvalidEndianness);
}
let section_count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
let mut sections = Vec::new();
let mut pos = 32;
for _ in 0..section_count {
if pos + 12 > bytes.len() {
return Err(LoadError::UnexpectedEof);
}
let kind =
u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]);
let offset = u32::from_le_bytes([
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
]);
let length = u32::from_le_bytes([
bytes[pos + 8],
bytes[pos + 9],
bytes[pos + 10],
bytes[pos + 11],
]);
// Basic bounds check
if (offset as usize) + (length as usize) > bytes.len() {
return Err(LoadError::SectionOutOfBounds);
}
sections.push((kind, offset, length));
pos += 12;
}
// Check for overlapping sections
for i in 0..sections.len() {
for j in i + 1..sections.len() {
let (_, o1, l1) = sections[i];
let (_, o2, l2) = sections[j];
if (o1 < o2 + l2) && (o2 < o1 + l1) {
return Err(LoadError::OverlappingSections);
}
}
}
let mut module = BytecodeModule {
version,
const_pool: Vec::new(),
functions: Vec::new(),
code: Vec::new(),
debug_info: None,
exports: Vec::new(),
syscalls: Vec::new(),
};
let mut has_syscalls = false;
for (kind, offset, length) in sections {
let section_data = &bytes[offset as usize..(offset + length) as usize];
match kind {
SECTION_KIND_CONST_POOL => {
// Const Pool
module.const_pool = parse_const_pool(section_data)?;
}
SECTION_KIND_FUNCTIONS => {
// Functions
module.functions = parse_functions(section_data)?;
}
SECTION_KIND_CODE => {
// Code
module.code = section_data.to_vec();
}
SECTION_KIND_DEBUG => {
// Debug Info
module.debug_info = Some(parse_debug_section(section_data)?);
}
SECTION_KIND_EXPORTS => {
// Exports
module.exports = parse_exports(section_data)?;
}
SECTION_KIND_SYSCALLS => {
module.syscalls = parse_syscalls(section_data)?;
has_syscalls = true;
}
_ => {} // Skip unknown or optional sections
}
}
if !has_syscalls {
return Err(LoadError::MissingSyscallSection);
}
// Additional validations
validate_module(&module)?;
Ok(module)
}
}
fn parse_const_pool(data: &[u8]) -> Result<Vec<ConstantPoolEntry>, LoadError> {
if data.is_empty() {
return Ok(Vec::new());
}
if data.len() < 4 {
return Err(LoadError::MalformedSection);
}
let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let mut cp = Vec::with_capacity(count);
let mut pos = 4;
for _ in 0..count {
if pos >= data.len() {
return Err(LoadError::UnexpectedEof);
}
let tag = data[pos];
pos += 1;
match tag {
0 => cp.push(ConstantPoolEntry::Null),
1 => {
// Int64
if pos + 8 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let val = i64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
cp.push(ConstantPoolEntry::Int64(val));
pos += 8;
}
2 => {
// Float64
if pos + 8 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let val = f64::from_le_bytes(data[pos..pos + 8].try_into().unwrap());
cp.push(ConstantPoolEntry::Float64(val));
pos += 8;
}
3 => {
// Boolean
if pos >= data.len() {
return Err(LoadError::UnexpectedEof);
}
cp.push(ConstantPoolEntry::Boolean(data[pos] != 0));
pos += 1;
}
4 => {
// String
if pos + 4 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + len > data.len() {
return Err(LoadError::UnexpectedEof);
}
let s = String::from_utf8_lossy(&data[pos..pos + len]).into_owned();
cp.push(ConstantPoolEntry::String(s));
pos += len;
}
5 => {
// Int32
if pos + 4 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let val = i32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
cp.push(ConstantPoolEntry::Int32(val));
pos += 4;
}
_ => return Err(LoadError::MalformedSection),
}
}
Ok(cp)
}
fn parse_functions(data: &[u8]) -> Result<Vec<FunctionMeta>, LoadError> {
if data.is_empty() {
return Ok(Vec::new());
}
if data.len() < 4 {
return Err(LoadError::MalformedSection);
}
let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let mut functions = Vec::with_capacity(count);
let mut pos = 4;
for _ in 0..count {
if pos + 16 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let code_offset = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
let code_len = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
let param_slots = u16::from_le_bytes(data[pos + 8..pos + 10].try_into().unwrap());
let local_slots = u16::from_le_bytes(data[pos + 10..pos + 12].try_into().unwrap());
let return_slots = u16::from_le_bytes(data[pos + 12..pos + 14].try_into().unwrap());
let max_stack_slots = u16::from_le_bytes(data[pos + 14..pos + 16].try_into().unwrap());
functions.push(FunctionMeta {
code_offset,
code_len,
param_slots,
local_slots,
return_slots,
max_stack_slots,
});
pos += 16;
}
Ok(functions)
}
fn parse_debug_section(data: &[u8]) -> Result<DebugInfo, LoadError> {
if data.is_empty() {
return Ok(DebugInfo::default());
}
if data.len() < 8 {
return Err(LoadError::MalformedSection);
}
let mut pos = 0;
// PC to Span table
let span_count = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
let mut pc_to_span = Vec::with_capacity(span_count);
for _ in 0..span_count {
if pos + 16 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let pc = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
let file_id = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
let start = u32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
let end = u32::from_le_bytes(data[pos + 12..pos + 16].try_into().unwrap());
pc_to_span.push((pc, SourceSpan { file_id, start, end }));
pos += 16;
}
// Function names table
if pos + 4 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let func_name_count = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
let mut function_names = Vec::with_capacity(func_name_count);
for _ in 0..func_name_count {
if pos + 8 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let func_idx = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
let name_len = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap()) as usize;
pos += 8;
if pos + name_len > data.len() {
return Err(LoadError::UnexpectedEof);
}
let name = String::from_utf8_lossy(&data[pos..pos + name_len]).into_owned();
function_names.push((func_idx, name));
pos += name_len;
}
Ok(DebugInfo { pc_to_span, function_names })
}
fn parse_exports(data: &[u8]) -> Result<Vec<Export>, LoadError> {
if data.is_empty() {
return Ok(Vec::new());
}
if data.len() < 4 {
return Err(LoadError::MalformedSection);
}
let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let mut exports = Vec::with_capacity(count);
let mut pos = 4;
for _ in 0..count {
if pos + 8 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let func_idx = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
let name_len = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap()) as usize;
pos += 8;
if pos + name_len > data.len() {
return Err(LoadError::UnexpectedEof);
}
let symbol = String::from_utf8_lossy(&data[pos..pos + name_len]).into_owned();
exports.push(Export { symbol, func_idx });
pos += name_len;
}
Ok(exports)
}
fn parse_syscalls(data: &[u8]) -> Result<Vec<SyscallDecl>, LoadError> {
if data.len() < 4 {
return Err(LoadError::MalformedSection);
}
let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let mut syscalls = Vec::with_capacity(count);
let mut pos = 4;
for _ in 0..count {
if pos + 2 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let module_len = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()) as usize;
pos += 2;
if pos + module_len > data.len() {
return Err(LoadError::UnexpectedEof);
}
let module = std::str::from_utf8(&data[pos..pos + module_len])
.map_err(|_| LoadError::InvalidUtf8)?;
pos += module_len;
if pos + 2 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()) as usize;
pos += 2;
if pos + name_len > data.len() {
return Err(LoadError::UnexpectedEof);
}
let name =
std::str::from_utf8(&data[pos..pos + name_len]).map_err(|_| LoadError::InvalidUtf8)?;
pos += name_len;
if pos + 6 > data.len() {
return Err(LoadError::UnexpectedEof);
}
let version = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap());
let arg_slots = u16::from_le_bytes(data[pos + 2..pos + 4].try_into().unwrap());
let ret_slots = u16::from_le_bytes(data[pos + 4..pos + 6].try_into().unwrap());
pos += 6;
syscalls.push(SyscallDecl {
module: module.to_owned(),
name: name.to_owned(),
version,
arg_slots,
ret_slots,
});
}
if pos != data.len() {
return Err(LoadError::MalformedSection);
}
Ok(syscalls)
}
fn validate_module(module: &BytecodeModule) -> Result<(), LoadError> {
let mut syscall_identities = HashSet::with_capacity(module.syscalls.len());
for syscall in &module.syscalls {
if !syscall_identities.insert((
syscall.module.clone(),
syscall.name.clone(),
syscall.version,
)) {
return Err(LoadError::DuplicateSyscallIdentity);
}
}
for func in &module.functions {
// Opcode stream bounds
if (func.code_offset as usize) + (func.code_len as usize) > module.code.len() {
return Err(LoadError::InvalidFunctionIndex);
}
}
// Basic opcode scan for const pool indices
let mut pos = 0;
while pos < module.code.len() {
if pos + 2 > module.code.len() {
break; // Unexpected EOF in middle of opcode, maybe should be error
}
let op_val = u16::from_le_bytes([module.code[pos], module.code[pos + 1]]);
let opcode = OpCode::try_from(op_val).map_err(|_| LoadError::InvalidOpcode)?;
pos += 2;
match opcode {
OpCode::PushConst => {
if pos + 4 > module.code.len() {
return Err(LoadError::UnexpectedEof);
}
let idx =
u32::from_le_bytes(module.code[pos..pos + 4].try_into().unwrap()) as usize;
if idx >= module.const_pool.len() {
return Err(LoadError::InvalidConstIndex);
}
pos += 4;
}
OpCode::PushI32
| OpCode::Jmp
| OpCode::JmpIfFalse
| OpCode::JmpIfTrue
| OpCode::GetGlobal
| OpCode::SetGlobal
| OpCode::GetLocal
| OpCode::SetLocal
| OpCode::PopN
| OpCode::Hostcall
| OpCode::Syscall
| OpCode::Intrinsic => {
pos += 4;
}
OpCode::PushI64 | OpCode::PushF64 => {
pos += 8;
}
OpCode::PushBool => {
pos += 1;
}
OpCode::Call => {
pos += 4;
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn create_header(section_count: u32) -> Vec<u8> {
let mut h = vec![0u8; 32];
h[0..4].copy_from_slice(b"PBS\0");
h[4..6].copy_from_slice(&0u16.to_le_bytes()); // version
h[6] = 0; // endianness
h[8..12].copy_from_slice(&section_count.to_le_bytes());
h
}
fn minimal_module() -> BytecodeModule {
BytecodeModule {
version: 0,
const_pool: vec![],
functions: vec![],
code: vec![],
debug_info: None,
exports: vec![],
syscalls: vec![],
}
}
fn build_pbs_with_sections(sections: Vec<(u32, Vec<u8>)>) -> Vec<u8> {
let mut data = create_header(sections.len() as u32);
let mut offset = 32 + (sections.len() as u32 * 12);
for (kind, section_data) in &sections {
data.extend_from_slice(&kind.to_le_bytes());
data.extend_from_slice(&offset.to_le_bytes());
data.extend_from_slice(&(section_data.len() as u32).to_le_bytes());
offset += section_data.len() as u32;
}
for (_, section_data) in sections {
data.extend_from_slice(&section_data);
}
data
}
#[test]
fn test_invalid_magic() {
let mut data = create_header(0);
data[0] = b'X';
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidMagic));
}
#[test]
fn test_invalid_version() {
let mut data = create_header(0);
data[4] = 1;
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidVersion));
}
#[test]
fn test_invalid_endianness() {
let mut data = create_header(0);
data[6] = 1;
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidEndianness));
}
#[test]
fn test_overlapping_sections() {
let mut data = create_header(2);
// Section 1: Kind 0, Offset 64, Length 32
data.extend_from_slice(&0u32.to_le_bytes());
data.extend_from_slice(&64u32.to_le_bytes());
data.extend_from_slice(&32u32.to_le_bytes());
// Section 2: Kind 1, Offset 80, Length 32 (Overlaps with Section 1)
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&80u32.to_le_bytes());
data.extend_from_slice(&32u32.to_le_bytes());
// Ensure data is long enough for the offsets
data.resize(256, 0);
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::OverlappingSections));
}
#[test]
fn test_section_out_of_bounds() {
let mut data = create_header(1);
// Section 1: Kind 0, Offset 64, Length 1000
data.extend_from_slice(&0u32.to_le_bytes());
data.extend_from_slice(&64u32.to_le_bytes());
data.extend_from_slice(&1000u32.to_le_bytes());
data.resize(256, 0);
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::SectionOutOfBounds));
}
#[test]
fn test_invalid_function_code_offset() {
let mut data = create_header(3);
// Section 1: Functions, Kind 1, Offset 80, Length 20 (Header 4 + 1 entry 16)
data.extend_from_slice(&1u32.to_le_bytes());
data.extend_from_slice(&80u32.to_le_bytes());
data.extend_from_slice(&20u32.to_le_bytes());
// Section 2: Code, Kind 2, Offset 128, Length 10
data.extend_from_slice(&2u32.to_le_bytes());
data.extend_from_slice(&128u32.to_le_bytes());
data.extend_from_slice(&10u32.to_le_bytes());
// Section 3: SYSC, Kind 5, Offset 160, Length 4 (empty)
data.extend_from_slice(&5u32.to_le_bytes());
data.extend_from_slice(&160u32.to_le_bytes());
data.extend_from_slice(&4u32.to_le_bytes());
data.resize(256, 0);
// Setup functions section
let func_data_start = 80;
data[func_data_start..func_data_start + 4].copy_from_slice(&1u32.to_le_bytes()); // 1 function
let entry_start = func_data_start + 4;
data[entry_start..entry_start + 4].copy_from_slice(&5u32.to_le_bytes()); // code_offset = 5
data[entry_start + 4..entry_start + 8].copy_from_slice(&10u32.to_le_bytes()); // code_len = 10
// 5 + 10 = 15 > 10 (code section length)
data[160..164].copy_from_slice(&0u32.to_le_bytes());
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidFunctionIndex));
}
#[test]
fn test_invalid_const_index() {
let mut data = create_header(3);
// Section 1: Const Pool, Kind 0, Offset 80, Length 4 (Empty CP)
data.extend_from_slice(&0u32.to_le_bytes());
data.extend_from_slice(&80u32.to_le_bytes());
data.extend_from_slice(&4u32.to_le_bytes());
// Section 2: Code, Kind 2, Offset 128, Length 6 (PushConst 0)
data.extend_from_slice(&2u32.to_le_bytes());
data.extend_from_slice(&128u32.to_le_bytes());
data.extend_from_slice(&6u32.to_le_bytes());
// Section 3: SYSC, Kind 5, Offset 160, Length 4 (empty)
data.extend_from_slice(&5u32.to_le_bytes());
data.extend_from_slice(&160u32.to_le_bytes());
data.extend_from_slice(&4u32.to_le_bytes());
data.resize(256, 0);
// Setup empty CP
data[80..84].copy_from_slice(&0u32.to_le_bytes());
// Setup code with PushConst 0
data[128..130].copy_from_slice(&(OpCode::PushConst as u16).to_le_bytes());
data[130..134].copy_from_slice(&0u32.to_le_bytes());
data[160..164].copy_from_slice(&0u32.to_le_bytes());
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidConstIndex));
}
#[test]
fn test_missing_sysc_section_is_rejected() {
let data = create_header(0);
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::MissingSyscallSection));
}
#[test]
fn test_valid_minimal_load_with_empty_sysc() {
let data = minimal_module().serialize();
let module = BytecodeLoader::load(&data).unwrap();
assert_eq!(module.version, 0);
assert!(module.const_pool.is_empty());
assert!(module.functions.is_empty());
assert!(module.code.is_empty());
assert!(module.syscalls.is_empty());
}
#[test]
fn test_valid_sysc_roundtrip() {
let mut module = minimal_module();
module.syscalls = vec![SyscallDecl {
module: "gfx2d".into(),
name: "draw_line".into(),
version: 1,
arg_slots: 4,
ret_slots: 0,
}];
let data = module.serialize();
let loaded = BytecodeLoader::load(&data).unwrap();
assert_eq!(loaded.syscalls, module.syscalls);
}
#[test]
fn test_malformed_sysc_section_is_rejected() {
let data = build_pbs_with_sections(vec![(SECTION_KIND_SYSCALLS, vec![1, 0, 0])]);
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::MalformedSection));
}
#[test]
fn test_invalid_utf8_in_sysc_section_is_rejected() {
let mut sysc = Vec::new();
sysc.extend_from_slice(&1u32.to_le_bytes());
sysc.extend_from_slice(&1u16.to_le_bytes());
sysc.push(0xFF);
sysc.extend_from_slice(&1u16.to_le_bytes());
sysc.push(b'x');
sysc.extend_from_slice(&1u16.to_le_bytes());
sysc.extend_from_slice(&0u16.to_le_bytes());
sysc.extend_from_slice(&0u16.to_le_bytes());
let data = build_pbs_with_sections(vec![(SECTION_KIND_SYSCALLS, sysc)]);
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::InvalidUtf8));
}
#[test]
fn test_duplicate_sysc_identity_is_rejected() {
let mut module = minimal_module();
module.syscalls = vec![
SyscallDecl {
module: "system".into(),
name: "has_cart".into(),
version: 1,
arg_slots: 0,
ret_slots: 1,
},
SyscallDecl {
module: "system".into(),
name: "has_cart".into(),
version: 1,
arg_slots: 0,
ret_slots: 1,
},
];
let data = module.serialize();
assert_eq!(BytecodeLoader::load(&data), Err(LoadError::DuplicateSyscallIdentity));
}
}

View File

@ -1,324 +0,0 @@
/// Represents a single instruction in the Prometeu Virtual Machine.
///
/// Each OpCode is encoded as a 16-bit unsigned integer (u16) in the bytecode.
/// The PVM is a stack-based machine, meaning most instructions take their
/// operands from the top of the stack and push their results back onto it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
pub enum OpCode {
// --- 6.1 Execution Control ---
/// No operation. Does nothing for 1 cycle.
Nop = 0x00,
/// Stops the Virtual Machine execution immediately.
Halt = 0x01,
/// Unconditional jump to a specific PC (Program Counter) address.
/// Operand: addr (u32)
Jmp = 0x02,
/// Jumps to `addr` if the value at the top of the stack is `false`.
/// Operand: addr (u32)
/// Stack: [bool] -> []
JmpIfFalse = 0x03,
/// Jumps to `addr` if the value at the top of the stack is `true`.
/// Operand: addr (u32)
/// Stack: [bool] -> []
JmpIfTrue = 0x04,
/// Triggers a software breakpoint. Used for debugging.
Trap = 0x05,
// --- 6.2 Stack Manipulation ---
/// Loads a constant from the Constant Pool into the stack.
/// Operand: index (u32)
/// Stack: [] -> [value]
PushConst = 0x10,
/// Removes the top value from the stack.
/// Stack: [val] -> []
Pop = 0x11,
/// Duplicates the top value of the stack.
/// Stack: [val] -> [val, val]
Dup = 0x12,
/// Swaps the two top values of the stack.
/// Stack: [a, b] -> [b, a]
Swap = 0x13,
/// Pushes a 64-bit integer literal onto the stack.
/// Operand: value (i64)
PushI64 = 0x14,
/// Pushes a 64-bit float literal onto the stack.
/// Operand: value (f64)
PushF64 = 0x15,
/// Pushes a boolean literal onto the stack (0=false, 1=true).
/// Operand: value (u8)
PushBool = 0x16,
/// Pushes a 32-bit integer literal onto the stack.
/// Operand: value (i32)
PushI32 = 0x17,
/// Removes `n` values from the stack.
/// Operand: n (u32)
PopN = 0x18,
// --- 6.3 Arithmetic ---
/// Adds the two top values (a + b).
/// Stack: [a, b] -> [result]
Add = 0x20,
/// Subtracts the top value from the second one (a - b).
/// Stack: [a, b] -> [result]
Sub = 0x21,
/// Multiplies the two top values (a * b).
/// Stack: [a, b] -> [result]
Mul = 0x22,
/// Divides the second top value by the top one (a / b).
/// Stack: [a, b] -> [result]
Div = 0x23,
/// Remainder of the division of the second top value by the top one (a % b).
/// Stack: [a, b] -> [result]
Mod = 0x24,
// --- 6.4 Comparison and Logic ---
/// Checks if a equals b.
/// Stack: [a, b] -> [bool]
Eq = 0x30,
/// Checks if a is not equal to b.
/// Stack: [a, b] -> [bool]
Neq = 0x31,
/// Checks if a is less than b.
/// Stack: [a, b] -> [bool]
Lt = 0x32,
/// Checks if a is greater than b.
/// Stack: [a, b] -> [bool]
Gt = 0x33,
/// Logical AND.
/// Stack: [bool, bool] -> [bool]
And = 0x34,
/// Logical OR.
/// Stack: [bool, bool] -> [bool]
Or = 0x35,
/// Logical NOT.
/// Stack: [bool] -> [bool]
Not = 0x36,
/// Bitwise AND.
/// Stack: [int, int] -> [int]
BitAnd = 0x37,
/// Bitwise OR.
/// Stack: [int, int] -> [int]
BitOr = 0x38,
/// Bitwise XOR.
/// Stack: [int, int] -> [int]
BitXor = 0x39,
/// Bitwise Shift Left.
/// Stack: [int, count] -> [int]
Shl = 0x3A,
/// Bitwise Shift Right.
/// Stack: [int, count] -> [int]
Shr = 0x3B,
/// Checks if a is less than or equal to b.
/// Stack: [a, b] -> [bool]
Lte = 0x3C,
/// Checks if a is greater than or equal to b.
/// Stack: [a, b] -> [bool]
Gte = 0x3D,
/// Negates a number (-a).
/// Stack: [num] -> [num]
Neg = 0x3E,
// --- 6.5 Variables ---
/// Loads a value from a global variable slot.
/// Operand: slot_index (u32)
/// Stack: [] -> [value]
GetGlobal = 0x40,
/// Stores the top value into a global variable slot.
/// Operand: slot_index (u32)
/// Stack: [value] -> []
SetGlobal = 0x41,
/// Loads a value from a local variable slot in the current frame.
/// Operand: slot_index (u32)
/// Stack: [] -> [value]
GetLocal = 0x42,
/// Stores the top value into a local variable slot in the current frame.
/// Operand: slot_index (u32)
/// Stack: [value] -> []
SetLocal = 0x43,
// --- 6.6 Functions ---
/// Calls a function by its index in the function table.
/// Operand: func_id (u32)
/// Stack: [arg0, arg1, ...] -> [return_slots...]
Call = 0x50,
/// Returns from the current function.
/// Stack: [return_val] -> [return_val]
Ret = 0x51,
/// Creates a closure capturing values from the operand stack (Model B).
/// Operands: fn_id (u32), capture_count (u32)
/// Stack before: [..., captured_N, ..., captured_1]
/// Pops capture_count values (top-first), preserves order as [captured_1..captured_N]
/// and stores them inside the closure environment. Pushes a HeapRef to the closure.
MakeClosure = 0x52,
/// Calls a closure value with hidden arg0 semantics (Model B).
/// Operand: arg_count (u32) — number of user-supplied args (excludes hidden arg0)
/// Stack before: [..., argN, ..., arg1, closure_ref]
/// Behavior:
/// - Pops `closure_ref` and validates it is a Closure.
/// - Pops `arg_count` user args.
/// - Fetches `fn_id` from the closure and creates a new call frame.
/// - Injects hidden arg0 = closure_ref, followed by user args as arg1..argN.
CallClosure = 0x53,
// --- 7.x Concurrency / Coroutines ---
/// Spawns a new coroutine to run a function with arguments.
/// Operands: fn_id (u32), arg_count (u32)
/// Semantics:
/// - Pops `arg_count` arguments from the current operand stack (top-first),
/// preserving user order as arg1..argN for the callee.
/// - Allocates a new Coroutine object with its own stack and a single entry frame
/// pointing at `fn_id`.
/// - Enqueues the coroutine into the scheduler ready queue.
/// - Does NOT switch execution immediately; current coroutine continues.
Spawn = 0x54,
/// Cooperatively yields the current coroutine. Execution continues
/// until the next VM safepoint (FRAME_SYNC), where the scheduler
/// may switch to another ready coroutine.
Yield = 0x55,
/// Suspends the current coroutine for a number of logical ticks.
/// Operand: duration_ticks (u32)
/// Semantics:
/// - Set the coroutine wake tick to `current_tick + duration_ticks`.
/// - End the current logical frame (as if reaching FRAME_SYNC).
/// - The coroutine will resume execution on or after the wake tick.
Sleep = 0x56,
// --- 6.8 Peripherals and System ---
/// Pre-load host binding call by `SYSC` table index.
/// Operand: sysc_index (u32)
/// This opcode is valid only in PBX artifact form and must be patched by the loader
/// into a final numeric `SYSCALL <id>` before verification or execution.
Hostcall = 0x71,
/// Invokes a final numeric system function (Firmware/OS).
/// Raw `SYSCALL` is valid only after loader patching and is rejected in PBX pre-load artifacts.
/// Operand: syscall_id (u32)
/// Stack: [args...] -> [results...] (depends on syscall)
Syscall = 0x70,
/// Invokes a VM-owned intrinsic by final numeric id.
/// Operand: intrinsic_id (u32)
/// Stack: [args...] -> [results...] (depends on intrinsic metadata)
Intrinsic = 0x72,
/// Synchronizes the VM with the hardware frame (usually 60Hz).
/// Execution pauses until the next VSync.
FrameSync = 0x80,
}
impl TryFrom<u16> for OpCode {
type Error = String;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
0x00 => Ok(OpCode::Nop),
0x01 => Ok(OpCode::Halt),
0x02 => Ok(OpCode::Jmp),
0x03 => Ok(OpCode::JmpIfFalse),
0x04 => Ok(OpCode::JmpIfTrue),
0x05 => Ok(OpCode::Trap),
0x10 => Ok(OpCode::PushConst),
0x11 => Ok(OpCode::Pop),
0x12 => Ok(OpCode::Dup),
0x13 => Ok(OpCode::Swap),
0x14 => Ok(OpCode::PushI64),
0x15 => Ok(OpCode::PushF64),
0x16 => Ok(OpCode::PushBool),
0x17 => Ok(OpCode::PushI32),
0x18 => Ok(OpCode::PopN),
0x20 => Ok(OpCode::Add),
0x21 => Ok(OpCode::Sub),
0x22 => Ok(OpCode::Mul),
0x23 => Ok(OpCode::Div),
0x24 => Ok(OpCode::Mod),
0x30 => Ok(OpCode::Eq),
0x31 => Ok(OpCode::Neq),
0x32 => Ok(OpCode::Lt),
0x33 => Ok(OpCode::Gt),
0x34 => Ok(OpCode::And),
0x35 => Ok(OpCode::Or),
0x36 => Ok(OpCode::Not),
0x37 => Ok(OpCode::BitAnd),
0x38 => Ok(OpCode::BitOr),
0x39 => Ok(OpCode::BitXor),
0x3A => Ok(OpCode::Shl),
0x3B => Ok(OpCode::Shr),
0x3C => Ok(OpCode::Lte),
0x3D => Ok(OpCode::Gte),
0x3E => Ok(OpCode::Neg),
0x40 => Ok(OpCode::GetGlobal),
0x41 => Ok(OpCode::SetGlobal),
0x42 => Ok(OpCode::GetLocal),
0x43 => Ok(OpCode::SetLocal),
0x50 => Ok(OpCode::Call),
0x51 => Ok(OpCode::Ret),
0x52 => Ok(OpCode::MakeClosure),
0x53 => Ok(OpCode::CallClosure),
0x54 => Ok(OpCode::Spawn),
0x55 => Ok(OpCode::Yield),
0x56 => Ok(OpCode::Sleep),
0x70 => Ok(OpCode::Syscall),
0x71 => Ok(OpCode::Hostcall),
0x72 => Ok(OpCode::Intrinsic),
0x80 => Ok(OpCode::FrameSync),
_ => Err(format!("Invalid OpCode: 0x{:04X}", value)),
}
}
}
impl OpCode {
/// Returns the cost of the instruction in VM cycles.
/// This is used for performance monitoring and resource limiting (Certification).
pub fn cycles(&self) -> u64 {
match self {
OpCode::Nop => 1,
OpCode::Halt => 1,
OpCode::Jmp => 2,
OpCode::JmpIfFalse => 3,
OpCode::JmpIfTrue => 3,
OpCode::Trap => 1,
OpCode::PushConst => 2,
OpCode::Pop => 1,
OpCode::PopN => 2,
OpCode::Dup => 1,
OpCode::Swap => 1,
OpCode::PushI64 => 2,
OpCode::PushF64 => 2,
OpCode::PushBool => 2,
OpCode::PushI32 => 2,
OpCode::Add => 2,
OpCode::Sub => 2,
OpCode::Mul => 4,
OpCode::Div => 6,
OpCode::Mod => 6,
OpCode::Eq => 2,
OpCode::Neq => 2,
OpCode::Lt => 2,
OpCode::Gt => 2,
OpCode::And => 2,
OpCode::Or => 2,
OpCode::Not => 1,
OpCode::BitAnd => 2,
OpCode::BitOr => 2,
OpCode::BitXor => 2,
OpCode::Shl => 2,
OpCode::Shr => 2,
OpCode::Lte => 2,
OpCode::Gte => 2,
OpCode::Neg => 1,
OpCode::GetGlobal => 3,
OpCode::SetGlobal => 3,
OpCode::GetLocal => 2,
OpCode::SetLocal => 2,
OpCode::Call => 5,
OpCode::Ret => 4,
OpCode::MakeClosure => 8,
OpCode::CallClosure => 6,
OpCode::Spawn => 6,
OpCode::Yield => 1,
OpCode::Sleep => 1,
OpCode::Syscall => 1,
OpCode::Hostcall => 1,
OpCode::Intrinsic => 1,
OpCode::FrameSync => 1,
}
}
}

View File

@ -1,567 +0,0 @@
use crate::opcode::OpCode;
/// Specification for a single OpCode.
/// All JMP/JMP_IF_* immediate are u32 absolute offsets from function start.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpcodeSpec {
pub name: &'static str,
pub imm_bytes: u8, // immediate payload size (decode)
pub pops: u16, // slots popped
pub pushes: u16, // slots pushed
pub is_branch: bool, // has a control-flow target
pub is_terminator: bool, // ends basic block: JMP/RET/TRAP/HALT
pub may_trap: bool, // runtime trap possible
/// Marks this opcode as a VM safepoint. Used by GC/scheduler policies.
pub is_safepoint: bool,
}
pub trait OpCodeSpecExt {
fn spec(&self) -> OpcodeSpec;
}
impl OpCodeSpecExt for OpCode {
fn spec(&self) -> OpcodeSpec {
match self {
OpCode::Nop => OpcodeSpec {
name: "NOP",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Halt => OpcodeSpec {
name: "HALT",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: true,
may_trap: false,
is_safepoint: false,
},
OpCode::Jmp => OpcodeSpec {
name: "JMP",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: true,
is_terminator: true,
may_trap: false,
is_safepoint: false,
},
OpCode::JmpIfFalse => OpcodeSpec {
name: "JMP_IF_FALSE",
imm_bytes: 4,
pops: 1,
pushes: 0,
is_branch: true,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::JmpIfTrue => OpcodeSpec {
name: "JMP_IF_TRUE",
imm_bytes: 4,
pops: 1,
pushes: 0,
is_branch: true,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Trap => OpcodeSpec {
name: "TRAP",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: true,
may_trap: true,
is_safepoint: false,
},
OpCode::PushConst => OpcodeSpec {
name: "PUSH_CONST",
imm_bytes: 4,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Pop => OpcodeSpec {
name: "POP",
imm_bytes: 0,
pops: 1,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::PopN => OpcodeSpec {
name: "POP_N",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Dup => OpcodeSpec {
name: "DUP",
imm_bytes: 0,
pops: 1,
pushes: 2,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Swap => OpcodeSpec {
name: "SWAP",
imm_bytes: 0,
pops: 2,
pushes: 2,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::PushI64 => OpcodeSpec {
name: "PUSH_I64",
imm_bytes: 8,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::PushF64 => OpcodeSpec {
name: "PUSH_F64",
imm_bytes: 8,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::PushBool => OpcodeSpec {
name: "PUSH_BOOL",
imm_bytes: 1,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::PushI32 => OpcodeSpec {
name: "PUSH_I32",
imm_bytes: 4,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Add => OpcodeSpec {
name: "ADD",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Sub => OpcodeSpec {
name: "SUB",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Mul => OpcodeSpec {
name: "MUL",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Div => OpcodeSpec {
name: "DIV",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Mod => OpcodeSpec {
name: "MOD",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Eq => OpcodeSpec {
name: "EQ",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Neq => OpcodeSpec {
name: "NEQ",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Lt => OpcodeSpec {
name: "LT",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Gt => OpcodeSpec {
name: "GT",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::And => OpcodeSpec {
name: "AND",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Or => OpcodeSpec {
name: "OR",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Not => OpcodeSpec {
name: "NOT",
imm_bytes: 0,
pops: 1,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::BitAnd => OpcodeSpec {
name: "BIT_AND",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::BitOr => OpcodeSpec {
name: "BIT_OR",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::BitXor => OpcodeSpec {
name: "BIT_XOR",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Shl => OpcodeSpec {
name: "SHL",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Shr => OpcodeSpec {
name: "SHR",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Lte => OpcodeSpec {
name: "LTE",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Gte => OpcodeSpec {
name: "GTE",
imm_bytes: 0,
pops: 2,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Neg => OpcodeSpec {
name: "NEG",
imm_bytes: 0,
pops: 1,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::GetGlobal => OpcodeSpec {
name: "GET_GLOBAL",
imm_bytes: 4,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::SetGlobal => OpcodeSpec {
name: "SET_GLOBAL",
imm_bytes: 4,
pops: 1,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::GetLocal => OpcodeSpec {
name: "GET_LOCAL",
imm_bytes: 4,
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::SetLocal => OpcodeSpec {
name: "SET_LOCAL",
imm_bytes: 4,
pops: 1,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Call => OpcodeSpec {
name: "CALL",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Ret => OpcodeSpec {
name: "RET",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: true,
may_trap: false,
is_safepoint: false,
},
OpCode::MakeClosure => OpcodeSpec {
name: "MAKE_CLOSURE",
// Two u32 immediates: fn_id and capture_count
imm_bytes: 8,
// Dynamic, depends on capture_count; keep 0 here for verifier-free spec
pops: 0,
pushes: 1,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::CallClosure => OpcodeSpec {
name: "CALL_CLOSURE",
// One u32 immediate: arg_count (user args, excludes hidden arg0)
imm_bytes: 4,
// Dynamic: pops closure_ref + arg_count; keep 0 in spec layer
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Spawn => OpcodeSpec {
name: "SPAWN",
// Two u32 immediates: fn_id and arg_count
imm_bytes: 8,
// Dynamic pops depends on arg_count; keep 0 here in spec layer
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Yield => OpcodeSpec {
name: "YIELD",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
// Not a block terminator; effect realized at safepoint
is_terminator: false,
may_trap: false,
// Treated as a safepoint marker for cooperative scheduling
is_safepoint: true,
},
OpCode::Sleep => OpcodeSpec {
name: "SLEEP",
// One u32 immediate: duration_ticks
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
// Ends execution at safepoint after instruction completes
is_terminator: false,
may_trap: false,
// Considered a safepoint since it forces a frame boundary
is_safepoint: true,
},
OpCode::Hostcall => OpcodeSpec {
name: "HOSTCALL",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: false,
},
OpCode::Syscall => OpcodeSpec {
name: "SYSCALL",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::Intrinsic => OpcodeSpec {
name: "INTRINSIC",
imm_bytes: 4,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: true,
is_safepoint: false,
},
OpCode::FrameSync => OpcodeSpec {
name: "FRAME_SYNC",
imm_bytes: 0,
pops: 0,
pushes: 0,
is_branch: false,
is_terminator: false,
may_trap: false,
is_safepoint: true,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Infer the numeric range from the TryFrom<u16> mapping used in opcode.rs tests
// by scanning a plausible range (0..1024) and keeping all successful decodes.
#[test]
fn every_opcode_has_spec_and_imm_defined() {
let mut count = 0usize;
for val in 0u16..=1023u16 {
if let Ok(op) = OpCode::try_from(val) {
let spec = op.spec();
// Access all fields to ensure they are present and not optimized away
let _ = (
spec.name,
spec.imm_bytes,
spec.pops,
spec.pushes,
spec.is_branch,
spec.is_terminator,
spec.may_trap,
);
count += 1;
}
}
assert!(count > 0, "No opcodes were found via OpCode::try_from");
}
}

View File

@ -1,124 +0,0 @@
use crate::abi::TrapInfo;
use crate::model::{BytecodeModule, ConstantPoolEntry, DebugInfo, Export, FunctionMeta};
use crate::value::Value;
use std::collections::HashMap;
use std::sync::Arc;
/// Represents a fully linked, executable PBS program image.
///
/// Under the Prometeu architecture, the ProgramImage is a "closed-world" artifact
/// produced by the compiler. All linking, relocation, and symbol resolution
/// MUST be performed by the compiler before this image is created.
///
/// The runtime (VM) assumes this image is authoritative and performs no
/// additional linking or fixups.
#[derive(Debug, Clone, Default)]
pub struct ProgramImage {
pub rom: Arc<[u8]>,
pub constant_pool: Arc<[Value]>,
pub functions: Arc<[FunctionMeta]>,
pub debug_info: Option<DebugInfo>,
pub exports: Arc<HashMap<String, u32>>,
}
impl ProgramImage {
pub fn new(
rom: Vec<u8>,
constant_pool: Vec<Value>,
functions: Vec<FunctionMeta>,
debug_info: Option<DebugInfo>,
exports: HashMap<String, u32>,
) -> Self {
Self {
rom: Arc::from(rom),
constant_pool: Arc::from(constant_pool),
functions: Arc::from(functions),
debug_info,
exports: Arc::new(exports),
}
}
pub fn create_trap(&self, code: u32, opcode: u16, mut message: String, pc: u32) -> TrapInfo {
let span = self
.debug_info
.as_ref()
.and_then(|di| di.pc_to_span.iter().find(|(p, _)| *p == pc).map(|(_, s)| s.clone()));
if let Some(func_idx) = self.find_function_index(pc) {
if let Some(func_name) = self.get_function_name(func_idx) {
message = format!("{} (in function {})", message, func_name);
}
}
TrapInfo { code, opcode, message, pc, span }
}
pub fn find_function_index(&self, pc: u32) -> Option<usize> {
self.functions.iter().position(|f| pc >= f.code_offset && pc < (f.code_offset + f.code_len))
}
pub fn get_function_name(&self, func_idx: usize) -> Option<&str> {
self.debug_info
.as_ref()
.and_then(|di| di.function_names.iter().find(|(idx, _)| *idx as usize == func_idx))
.map(|(_, name)| name.as_str())
}
}
impl From<BytecodeModule> for ProgramImage {
fn from(module: BytecodeModule) -> Self {
let constant_pool: Vec<Value> = module
.const_pool
.iter()
.map(|entry| match entry {
ConstantPoolEntry::Null => Value::Null,
ConstantPoolEntry::Int64(v) => Value::Int64(*v),
ConstantPoolEntry::Float64(v) => Value::Float(*v),
ConstantPoolEntry::Boolean(v) => Value::Boolean(*v),
ConstantPoolEntry::String(v) => Value::string(v.clone()),
ConstantPoolEntry::Int32(v) => Value::Int32(*v),
})
.collect();
let mut exports = HashMap::new();
for export in module.exports {
exports.insert(export.symbol, export.func_idx);
}
ProgramImage::new(module.code, constant_pool, module.functions, module.debug_info, exports)
}
}
impl From<ProgramImage> for BytecodeModule {
fn from(program: ProgramImage) -> Self {
let const_pool = program
.constant_pool
.iter()
.map(|v| match v {
Value::Null => ConstantPoolEntry::Null,
Value::Int64(v) => ConstantPoolEntry::Int64(*v),
Value::Float(v) => ConstantPoolEntry::Float64(*v),
Value::Boolean(v) => ConstantPoolEntry::Boolean(*v),
Value::String(v) => ConstantPoolEntry::String(v.to_string()),
Value::Int32(v) => ConstantPoolEntry::Int32(*v),
Value::HeapRef(_) => ConstantPoolEntry::Null,
})
.collect();
let exports = program
.exports
.iter()
.map(|(symbol, &func_idx)| Export { symbol: symbol.clone(), func_idx })
.collect();
BytecodeModule {
version: 0,
const_pool,
functions: program.functions.as_ref().to_vec(),
code: program.rom.as_ref().to_vec(),
debug_info: program.debug_info.clone(),
exports,
syscalls: Vec::new(),
}
}
}

View File

@ -1,46 +0,0 @@
use prometeu_bytecode::isa::core::CoreOpCode;
use prometeu_bytecode::{assemble, disassemble};
fn emit(op: CoreOpCode, imm: Option<&[u8]>, out: &mut Vec<u8>) {
out.extend_from_slice(&(op as u16).to_le_bytes());
if let Some(bytes) = imm {
out.extend_from_slice(bytes);
}
}
#[test]
fn roundtrip_disasm_assemble_byte_equal_with_closures_and_coroutines() {
// Program: PUSH_I32 7; MAKE_CLOSURE fn=1,captures=0; CALL_CLOSURE argc=1;
// SPAWN fn=2,argc=1; YIELD; SLEEP 3; HOSTCALL 2; SYSCALL 0x1003; FRAME_SYNC; HALT
let mut prog = Vec::new();
emit(CoreOpCode::PushI32, Some(&7i32.to_le_bytes()), &mut prog);
// MAKE_CLOSURE (fn=1, captures=0)
let mut mc = [0u8; 8];
mc[0..4].copy_from_slice(&1u32.to_le_bytes());
mc[4..8].copy_from_slice(&0u32.to_le_bytes());
emit(CoreOpCode::MakeClosure, Some(&mc), &mut prog);
// CALL_CLOSURE argc=1
emit(CoreOpCode::CallClosure, Some(&1u32.to_le_bytes()), &mut prog);
// SPAWN (fn=2, argc=1)
let mut sp = [0u8; 8];
sp[0..4].copy_from_slice(&2u32.to_le_bytes());
sp[4..8].copy_from_slice(&1u32.to_le_bytes());
emit(CoreOpCode::Spawn, Some(&sp), &mut prog);
// YIELD
emit(CoreOpCode::Yield, None, &mut prog);
// SLEEP 3
emit(CoreOpCode::Sleep, Some(&3u32.to_le_bytes()), &mut prog);
// HOSTCALL sysc[2]
emit(CoreOpCode::Hostcall, Some(&2u32.to_le_bytes()), &mut prog);
// SYSCALL gfx2d.draw_line (0x1003)
emit(CoreOpCode::Syscall, Some(&0x1003u32.to_le_bytes()), &mut prog);
// FRAME_SYNC
emit(CoreOpCode::FrameSync, None, &mut prog);
// HALT
emit(CoreOpCode::Halt, None, &mut prog);
let text = disassemble(&prog).expect("disasm ok");
let rebuilt = assemble(&text).expect("assemble ok");
assert_eq!(rebuilt, prog, "re-assembled bytes must match original");
}

View File

@ -1,43 +0,0 @@
use prometeu_bytecode::disassemble;
use prometeu_bytecode::isa::core::CoreOpCode;
fn emit(op: CoreOpCode, imm: Option<&[u8]>, out: &mut Vec<u8>) {
out.extend_from_slice(&(op as u16).to_le_bytes());
if let Some(bytes) = imm {
out.extend_from_slice(bytes);
}
}
#[test]
fn snapshot_representative_program_is_stable() {
let mut prog = Vec::new();
emit(CoreOpCode::PushI32, Some(&7i32.to_le_bytes()), &mut prog);
// MAKE_CLOSURE (fn=1, captures=0)
let mut mc = [0u8; 8];
mc[0..4].copy_from_slice(&1u32.to_le_bytes());
mc[4..8].copy_from_slice(&0u32.to_le_bytes());
emit(CoreOpCode::MakeClosure, Some(&mc), &mut prog);
// CALL_CLOSURE argc=1
emit(CoreOpCode::CallClosure, Some(&1u32.to_le_bytes()), &mut prog);
// SPAWN (fn=2, argc=1)
let mut sp = [0u8; 8];
sp[0..4].copy_from_slice(&2u32.to_le_bytes());
sp[4..8].copy_from_slice(&1u32.to_le_bytes());
emit(CoreOpCode::Spawn, Some(&sp), &mut prog);
// YIELD
emit(CoreOpCode::Yield, None, &mut prog);
// SLEEP 3
emit(CoreOpCode::Sleep, Some(&3u32.to_le_bytes()), &mut prog);
// HOSTCALL 2
emit(CoreOpCode::Hostcall, Some(&2u32.to_le_bytes()), &mut prog);
// SYSCALL 0x1003
emit(CoreOpCode::Syscall, Some(&0x1003u32.to_le_bytes()), &mut prog);
// FRAME_SYNC
emit(CoreOpCode::FrameSync, None, &mut prog);
// HALT
emit(CoreOpCode::Halt, None, &mut prog);
let text = disassemble(&prog).expect("disasm ok");
let expected = "PUSH_I32 7\nMAKE_CLOSURE fn=1, captures=0\nCALL_CLOSURE argc=1\nSPAWN fn=2, argc=1\nYIELD\nSLEEP 3\nHOSTCALL 2\nSYSCALL 0x1003\nFRAME_SYNC\nHALT";
assert_eq!(text, expected);
}

View File

@ -1,160 +0,0 @@
use prometeu_bytecode::decode_next;
use prometeu_bytecode::isa::core::{CoreOpCode, CoreOpCodeSpecExt};
fn encode_instr(op: CoreOpCode, imm: Option<&[u8]>) -> Vec<u8> {
let mut out = Vec::new();
let code = op as u16;
out.extend_from_slice(&code.to_le_bytes());
let spec = op.spec();
let need = spec.imm_bytes as usize;
match (need, imm) {
(0, None) => {}
(n, Some(bytes)) if bytes.len() == n => out.extend_from_slice(bytes),
(n, Some(bytes)) => {
panic!("immediate size mismatch for {:?}: expected {}, got {}", op, n, bytes.len())
}
(n, None) => panic!("missing immediate for {:?}: need {} bytes", op, n),
}
out
}
fn disasm(bytes: &[u8]) -> String {
// Minimal test-only disasm: NAME [operands]
let mut pc = 0usize;
let mut lines = Vec::new();
while pc < bytes.len() {
match decode_next(pc, bytes) {
Ok(instr) => {
let name = instr.opcode.spec().name;
let mut line = String::from(name);
let imm_len = instr.opcode.spec().imm_bytes as usize;
if imm_len > 0 {
// Heuristic formatting based on known op immediates
line.push(' ');
let s = match instr.opcode {
CoreOpCode::Jmp | CoreOpCode::JmpIfFalse | CoreOpCode::JmpIfTrue => {
format!("{}", instr.imm_u32().unwrap())
}
CoreOpCode::PushI64 => format!("{}", instr.imm_i64().unwrap()),
CoreOpCode::PushF64 => format!("{}", instr.imm_f64().unwrap()),
CoreOpCode::PushBool => format!("{}", instr.imm_u8().unwrap()),
CoreOpCode::PushI32 => format!("{}", instr.imm_i32().unwrap()),
CoreOpCode::PopN | CoreOpCode::PushConst | CoreOpCode::Hostcall => {
format!("{}", instr.imm_u32().unwrap())
}
CoreOpCode::Syscall | CoreOpCode::Intrinsic => {
format!("0x{}", hex::encode(instr.imm))
}
_ => format!("0x{}", hex::encode(instr.imm)),
};
line.push_str(&s);
}
lines.push(line);
pc = instr.next_pc;
}
Err(_) => break,
}
}
lines.join("\n")
}
#[test]
fn encode_decode_roundtrip_preserves_structure() {
// Program: PUSH_I32 42; PUSH_I32 100; ADD; PUSH_BOOL 1; JMP 12; NOP; HALT
let mut prog = Vec::new();
prog.extend(encode_instr(CoreOpCode::PushI32, Some(&42i32.to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::PushI32, Some(&100i32.to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::Add, None));
prog.extend(encode_instr(CoreOpCode::PushBool, Some(&[1u8])));
// Jump to the HALT (compute absolute PC within this byte slice)
// Current pc after previous: 2+4 + 2+4 + 2 + 2+1 = 17 bytes
// Next we place: JMP (2+4), NOP (2), HALT (2)
// We want JMP target to land at the HALT's pc
let jmp_target: u32 = 17 + 2 + 4 + 2; // pc where HALT starts
prog.extend(encode_instr(CoreOpCode::Jmp, Some(&jmp_target.to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::Nop, None));
prog.extend(encode_instr(CoreOpCode::Halt, None));
// Decode sequentially and check opcodes and immediates
let mut pc = 0usize;
let mut seen = Vec::new();
while pc < prog.len() {
let instr = decode_next(pc, &prog).expect("decode ok");
seen.push(instr);
pc = instr.next_pc;
}
assert_eq!(seen.len(), 7);
assert_eq!(seen[0].opcode, CoreOpCode::PushI32);
assert_eq!(seen[0].imm_i32().unwrap(), 42);
assert_eq!(seen[1].opcode, CoreOpCode::PushI32);
assert_eq!(seen[1].imm_i32().unwrap(), 100);
assert_eq!(seen[2].opcode, CoreOpCode::Add);
assert_eq!(seen[3].opcode, CoreOpCode::PushBool);
assert_eq!(seen[3].imm_u8().unwrap(), 1);
assert_eq!(seen[4].opcode, CoreOpCode::Jmp);
assert_eq!(seen[4].imm_u32().unwrap(), jmp_target);
assert_eq!(seen[5].opcode, CoreOpCode::Nop);
assert_eq!(seen[6].opcode, CoreOpCode::Halt);
}
#[test]
fn disasm_contains_expected_mnemonics_and_operands() {
// Tiny deterministic sample: NOP; PUSH_I32 -7; PUSH_BOOL 0; ADD; HALT
let mut prog = Vec::new();
prog.extend(encode_instr(CoreOpCode::Nop, None));
prog.extend(encode_instr(CoreOpCode::PushI32, Some(&(-7i32).to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::PushBool, Some(&[0u8])));
prog.extend(encode_instr(CoreOpCode::Add, None));
prog.extend(encode_instr(CoreOpCode::Halt, None));
let text = disasm(&prog);
// Must contain stable opcode names and operand text
assert!(text.contains("NOP"));
assert!(text.contains("PUSH_I32 -7"));
assert!(text.contains("PUSH_BOOL 0"));
assert!(text.contains("ADD"));
assert!(text.contains("HALT"));
}
#[test]
fn hostcall_roundtrips_with_decimal_index() {
let mut prog = Vec::new();
prog.extend(encode_instr(CoreOpCode::Hostcall, Some(&7u32.to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::Halt, None));
let text = disasm(&prog);
assert!(text.contains("HOSTCALL 7"));
let rebuilt = prometeu_bytecode::assemble(&text).expect("assemble hostcall");
assert_eq!(rebuilt, prog);
}
#[test]
fn intrinsic_roundtrips_with_hex_id() {
let mut prog = Vec::new();
prog.extend(encode_instr(CoreOpCode::Intrinsic, Some(&0x1000u32.to_le_bytes())));
prog.extend(encode_instr(CoreOpCode::Halt, None));
let text = prometeu_bytecode::disassemble(&prog).expect("disasm intrinsic");
assert!(text.contains("INTRINSIC 0x1000"));
let rebuilt = prometeu_bytecode::assemble(&text).expect("assemble intrinsic");
assert_eq!(rebuilt, prog);
}
// Minimal hex helper to avoid extra deps in tests
mod hex {
pub fn encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
const HEX: &[u8; 16] = b"0123456789abcdef";
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
}

View File

@ -1,9 +0,0 @@
[package]
name = "prometeu-drivers"
version = "0.1.0"
edition = "2024"
license.workspace = true
[dependencies]
serde_json = "1.0.149"
prometeu-hal = { path = "../prometeu-hal" }

File diff suppressed because it is too large Load Diff

View File

@ -1,332 +0,0 @@
use prometeu_hal::{AudioBridge, AudioOpStatus};
use std::sync::Arc;
/// Maximum number of simultaneous audio voices supported by the hardware.
pub const MAX_CHANNELS: usize = 16;
/// Standard sample rate for the final audio output.
pub const OUTPUT_SAMPLE_RATE: u32 = 48000;
use crate::memory_banks::SoundBankPoolAccess;
/// Looping mode for samples (re-exported from the hardware contract).
pub use prometeu_hal::LoopMode;
use prometeu_hal::sample::Sample;
/// State of a single playback voice (channel).
///
/// The Core maintains this state to provide information to the App (e.g., is_playing),
/// but the actual real-time mixing is performed by the Host using commands.
pub struct Channel {
/// The actual sample data being played.
pub sample: Option<Arc<Sample>>,
/// Whether this channel is currently active.
pub active: bool,
/// Current playback position within the sample (fractional for pitch shifting).
pub pos: f64,
/// Playback speed multiplier (1.0 = original speed).
pub pitch: f64,
/// Voice volume (0-255).
pub volume: u8,
/// Stereo panning (0=Full Left, 127=Center, 255=Full Right).
pub pan: u8,
/// Loop configuration for this voice.
pub loop_mode: LoopMode,
/// Playback priority (used for voice stealing policies).
pub priority: u8,
}
impl Default for Channel {
fn default() -> Self {
Self {
sample: None,
active: false,
pos: 0.0,
pitch: 1.0,
volume: 255,
pan: 127,
loop_mode: LoopMode::Off,
priority: 0,
}
}
}
/// Commands sent from the Core to the Host audio backend.
///
/// Because the Core logic runs at 60Hz and Audio is generated at 48kHz,
/// we use an asynchronous command queue to synchronize them.
pub enum AudioCommand {
/// Start playing a sample on a specific voice.
Play {
sample: Arc<Sample>,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: LoopMode,
},
/// Immediately stop playback on a voice.
Stop { voice_id: usize },
/// Update volume of an ongoing playback.
SetVolume { voice_id: usize, volume: u8 },
/// Update panning of an ongoing playback.
SetPan { voice_id: usize, pan: u8 },
/// Update pitch of an ongoing playback.
SetPitch { voice_id: usize, pitch: f64 },
/// Pause all audio processing.
MasterPause,
/// Resume audio processing.
MasterResume,
}
/// PROMETEU Audio Subsystem.
///
/// Models a multi-channel PCM sampler (SPU).
/// The audio system in Prometeu is **command-based**. This means the Core
/// doesn't generate raw audio samples; instead, it sends high-level commands
/// (like `Play`, `Stop`, `SetVolume`) to a queue. The physical host backend
/// (e.g., CPAL on desktop) then consumes these commands and performs the
/// actual mixing at the native hardware sample rate.
///
/// ### Key Features:
/// - **16 Simultaneous Voices**: Independent volume, pan, and pitch.
/// - **Sample-based Synthesis**: Plays PCM data stored in SoundBanks.
/// - **Stereo Output**: 48kHz output target.
pub struct Audio {
/// Local state of the hardware voices. This state is used for logic
/// (e.g., checking if a sound is still playing) and is synchronized with the Host.
pub voices: [Channel; MAX_CHANNELS],
/// Queue of pending commands to be processed by the Host mixer.
/// This queue is typically flushed and sent to the Host once per frame.
pub commands: Vec<AudioCommand>,
/// Interface to access sound memory banks (ARAM).
pub sound_banks: Arc<dyn SoundBankPoolAccess>,
}
impl AudioBridge for Audio {
fn play(
&mut self,
bank_id: u8,
sample_id: u16,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: prometeu_hal::LoopMode,
) -> AudioOpStatus {
let lm = match loop_mode {
prometeu_hal::LoopMode::Off => LoopMode::Off,
prometeu_hal::LoopMode::On => LoopMode::On,
};
self.play(bank_id, sample_id, voice_id, volume, pan, pitch, priority, lm)
}
fn play_sample(
&mut self,
sample: Arc<Sample>,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: prometeu_hal::LoopMode,
) -> AudioOpStatus {
let lm = match loop_mode {
prometeu_hal::LoopMode::Off => LoopMode::Off,
prometeu_hal::LoopMode::On => LoopMode::On,
};
self.play_sample(sample, voice_id, volume, pan, pitch, priority, lm)
}
fn stop(&mut self, voice_id: usize) {
self.stop(voice_id)
}
fn set_volume(&mut self, voice_id: usize, volume: u8) {
self.set_volume(voice_id, volume)
}
fn set_pan(&mut self, voice_id: usize, pan: u8) {
self.set_pan(voice_id, pan)
}
fn set_pitch(&mut self, voice_id: usize, pitch: f64) {
self.set_pitch(voice_id, pitch)
}
fn is_playing(&self, voice_id: usize) -> bool {
self.is_playing(voice_id)
}
fn clear_commands(&mut self) {
self.clear_commands()
}
}
impl Audio {
/// Initializes the audio system with empty voices and sound bank access.
pub fn new(sound_banks: Arc<dyn SoundBankPoolAccess>) -> Self {
Self {
voices: std::array::from_fn(|_| Channel::default()),
commands: Vec::new(),
sound_banks,
}
}
#[allow(clippy::too_many_arguments)]
pub fn play(
&mut self,
bank_id: u8,
sample_id: u16,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: LoopMode,
) -> AudioOpStatus {
if voice_id >= MAX_CHANNELS {
return AudioOpStatus::VoiceInvalid;
}
// Resolve the sample from the hardware pools
let bank_slot = self.sound_banks.sound_bank_slot(bank_id as usize);
if bank_slot.is_none() {
return AudioOpStatus::BankInvalid;
}
let sample =
bank_slot.and_then(|bank| bank.samples.get(sample_id as usize).map(Arc::clone));
if let Some(s) = sample {
// println!(
// "[Audio] Resolved sample from bank {} sample {}. Playing on voice {}.",
// bank_id, sample_id, voice_id
// );
self.play_sample(s, voice_id, volume, pan, pitch, priority, loop_mode)
} else {
AudioOpStatus::SampleNotFound
}
}
#[allow(clippy::too_many_arguments)]
pub fn play_sample(
&mut self,
sample: Arc<Sample>,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: LoopMode,
) -> AudioOpStatus {
if voice_id >= MAX_CHANNELS {
return AudioOpStatus::VoiceInvalid;
}
// Update local state
self.voices[voice_id] = Channel {
sample: Some(Arc::clone(&sample)),
active: true,
pos: 0.0,
pitch,
volume,
pan,
loop_mode,
priority,
};
// Push command to the host
self.commands.push(AudioCommand::Play {
sample,
voice_id,
volume,
pan,
pitch,
priority,
loop_mode,
});
AudioOpStatus::Ok
}
pub fn stop(&mut self, voice_id: usize) {
if voice_id < MAX_CHANNELS {
self.voices[voice_id].active = false;
self.voices[voice_id].sample = None;
self.commands.push(AudioCommand::Stop { voice_id });
}
}
pub fn set_volume(&mut self, voice_id: usize, volume: u8) {
if voice_id < MAX_CHANNELS {
self.voices[voice_id].volume = volume;
self.commands.push(AudioCommand::SetVolume { voice_id, volume });
}
}
pub fn set_pan(&mut self, voice_id: usize, pan: u8) {
if voice_id < MAX_CHANNELS {
self.voices[voice_id].pan = pan;
self.commands.push(AudioCommand::SetPan { voice_id, pan });
}
}
pub fn set_pitch(&mut self, voice_id: usize, pitch: f64) {
if voice_id < MAX_CHANNELS {
self.voices[voice_id].pitch = pitch;
self.commands.push(AudioCommand::SetPitch { voice_id, pitch });
}
}
pub fn is_playing(&self, voice_id: usize) -> bool {
if voice_id < MAX_CHANNELS { self.voices[voice_id].active } else { false }
}
/// Clears the command queue. The Host should consume this every frame.
pub fn clear_commands(&mut self) {
self.commands.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_banks::{MemoryBanks, SoundBankPoolAccess, SoundBankPoolInstaller};
use prometeu_hal::sound_bank::SoundBank;
fn sample() -> Arc<Sample> {
Arc::new(Sample::new(44_100, vec![0, 1, 0, -1]))
}
#[test]
fn play_returns_voice_invalid_for_out_of_range_voice() {
let banks = Arc::new(MemoryBanks::new());
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status = audio.play(0, 0, MAX_CHANNELS, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::VoiceInvalid);
assert!(audio.commands.is_empty());
}
#[test]
fn play_returns_sample_not_found_when_sample_is_missing() {
let banks = Arc::new(MemoryBanks::new());
let installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
installer.install_sound_bank(0, Arc::new(SoundBank::new(vec![sample()])));
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status = audio.play(0, 1, 0, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::SampleNotFound);
assert!(audio.commands.is_empty());
}
#[test]
fn play_sample_returns_voice_invalid_without_side_effects() {
let banks = Arc::new(MemoryBanks::new());
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status = audio.play_sample(sample(), MAX_CHANNELS, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::VoiceInvalid);
assert!(!audio.voices.iter().any(|voice| voice.active));
assert!(audio.commands.is_empty());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,436 +0,0 @@
use crate::asset::AssetManager;
use crate::audio::Audio;
use crate::frame_composer::FrameComposer;
use crate::gfx::Gfx;
use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
};
use crate::pad::Pad;
use crate::touch::Touch;
use prometeu_hal::cartridge::AssetsPayloadSource;
use prometeu_hal::sprite::Sprite;
use prometeu_hal::{
AssetBridge, AudioBridge, InputPlatform, NoopTelemetryPlatform, PadBridge, RenderBackend,
RuntimePlatform, TelemetryPlatform, TouchBridge,
};
use prometeu_hal::{
Game2DFrameComposer, Game2DFramePacket, RenderSubmission, RenderSubmissionPacket,
};
use prometeu_hal::{RenderSubmissionSink, RenderSubmitError};
use std::sync::Arc;
/// Aggregate structure for all virtual hardware peripherals.
///
/// This struct represents the "Mainboard" of the PROMETEU console.
/// It acts as a container for all hardware subsystems. In the Prometeu
/// architecture, hardware is decoupled from the OS and VM, allowing
/// for easier testing and different host implementations (Desktop, Web, etc.).
///
/// ### Console Specifications:
/// - **Resolution**: 480x270 (16:9 Aspect Ratio).
/// - **Color Format**: RGBA8888.
/// - **Audio**: Stereo, Command-based mixing.
/// - **Input**: 12-button Digital Gamepad + Absolute Touch/Mouse.
pub struct Hardware {
/// Shared physical memory bank ownership used by peripherals and worker backends.
pub memory_banks: Arc<MemoryBanks>,
/// The Graphics Processing Unit (GPU). Handles drawing primitives, sprites, and tilemaps.
pub gfx: Gfx,
/// Canonical frame orchestration owner for scene/camera/cache/resolver/sprites.
pub frame_composer: FrameComposer,
/// The Sound Processing Unit (SPU). Manages sample playback and volume control.
pub audio: Audio,
/// The standard digital gamepad. Provides state for D-Pad, face buttons, and triggers.
pub pad: Pad,
/// The absolute pointer input device (Mouse/Touchscreen).
pub touch: Touch,
/// The Asset Management system (DMA). Handles loading data into VRAM (GlyphBanks) and ARAM (SoundBanks).
pub assets: AssetManager,
}
impl Default for Hardware {
fn default() -> Self {
Self::new()
}
}
impl Hardware {
fn consume_render_submission(&mut self, submission: &RenderSubmission) {
match &submission.packet {
RenderSubmissionPacket::Game2D(packet) => {
if packet.composer.bound_scene.is_none() {
self.gfx.render_game2d_frame_packet(packet);
} else {
self.frame_composer.render_game2d_frame_packet(packet, &mut self.gfx);
self.gfx.apply_gfx2d_commands(&packet.gfx2d);
}
}
RenderSubmissionPacket::ShellUi(packet) => {
self.gfx.render_shell_ui_frame_packet(packet);
}
}
self.gfx.present();
}
}
impl RenderSubmissionSink for Hardware {
fn submit_render_submission(
&mut self,
submission: RenderSubmission,
) -> Result<(), RenderSubmitError> {
self.consume_render_submission(&submission);
Ok(())
}
}
impl RenderBackend for Hardware {
fn render_frame(&mut self) {
if self.frame_composer.active_scene_id().is_none() {
let packet = self.frame_composer.close_game2d_packet();
self.gfx.render_game2d_frame_packet(&packet);
} else {
self.frame_composer.render_frame(&mut self.gfx);
}
}
}
impl Game2DFrameComposer for Hardware {
fn begin_frame(&mut self) {
self.frame_composer.begin_frame();
}
fn bind_scene(&mut self, scene_bank_id: usize) -> prometeu_hal::ComposerOpStatus {
if self.frame_composer.bind_scene(scene_bank_id) {
prometeu_hal::ComposerOpStatus::Ok
} else {
prometeu_hal::ComposerOpStatus::SceneUnavailable
}
}
fn unbind_scene(&mut self) {
self.frame_composer.unbind_scene();
}
fn set_camera(&mut self, x: i32, y: i32) -> prometeu_hal::ComposerOpStatus {
self.frame_composer.set_camera(x, y);
prometeu_hal::ComposerOpStatus::Ok
}
fn emit_sprite(&mut self, sprite: Sprite) -> prometeu_hal::ComposerOpStatus {
if self.gfx.glyph_banks.glyph_bank_slot(sprite.bank_id as usize).is_none() {
return prometeu_hal::ComposerOpStatus::BankInvalid;
}
if self.frame_composer.emit_sprite(sprite) {
prometeu_hal::ComposerOpStatus::Ok
} else {
prometeu_hal::ComposerOpStatus::SpriteOverflow
}
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
self.frame_composer.close_game2d_packet()
}
}
impl InputPlatform for Hardware {
fn pad(&self) -> &dyn PadBridge {
&self.pad
}
fn pad_mut(&mut self) -> &mut dyn PadBridge {
&mut self.pad
}
fn touch(&self) -> &dyn TouchBridge {
&self.touch
}
fn touch_mut(&mut self) -> &mut dyn TouchBridge {
&mut self.touch
}
}
static NOOP_TELEMETRY: NoopTelemetryPlatform = NoopTelemetryPlatform;
impl RuntimePlatform for Hardware {
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink {
self
}
fn render_backend(&mut self) -> &mut dyn RenderBackend {
self
}
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer {
self
}
fn audio(&self) -> &dyn AudioBridge {
&self.audio
}
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
&mut self.audio
}
fn input(&self) -> &dyn InputPlatform {
self
}
fn input_mut(&mut self) -> &mut dyn InputPlatform {
self
}
fn assets(&self) -> &dyn AssetBridge {
&self.assets
}
fn assets_mut(&mut self) -> &mut dyn AssetBridge {
&mut self.assets
}
fn telemetry(&self) -> &dyn TelemetryPlatform {
&NOOP_TELEMETRY
}
}
impl Hardware {
/// Internal hardware width in pixels.
pub const W: usize = 480;
/// Internal hardware height in pixels.
pub const H: usize = 270;
/// Creates a fresh hardware instance with default settings.
pub fn new() -> Self {
Self::new_with_memory_banks(Arc::new(MemoryBanks::new()))
}
/// Creates hardware with explicit shared bank ownership.
pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self {
let assets = AssetManager::new(
vec![],
AssetsPayloadSource::empty(),
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolInstaller>,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolInstaller>,
);
let glyph_slot_index = assets.glyph_asset_slot_index();
Self {
memory_banks: Arc::clone(&memory_banks),
gfx: Gfx::new(
Self::W,
Self::H,
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolAccess>,
),
frame_composer: FrameComposer::new(
Self::W,
Self::H,
Arc::clone(&memory_banks) as Arc<dyn SceneBankPoolAccess>,
glyph_slot_index,
),
audio: Audio::new(Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolAccess>),
pad: Pad::default(),
touch: Touch::default(),
assets,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_banks::{
GlyphBankPoolInstaller, SceneBankPoolAccess, SceneBankPoolInstaller,
};
use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
use prometeu_hal::primitives::Rect;
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::scene_viewport_cache::SceneViewportCache;
use prometeu_hal::scene_viewport_resolver::SceneViewportResolver;
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
use prometeu_hal::{
BoundScenePacket, Camera2D, ComposerFramePacket, FrameId, GameSpritePacket, Gfx2dCommand,
GfxUiCommand, ShellUiFramePacket,
};
fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
bank.palettes[0][1] = Color::RED;
for pixel in &mut bank.pixel_indices {
*pixel = 1;
}
bank
}
fn make_scene() -> SceneBank {
make_scene_with_glyph_asset_id(1)
}
fn make_scene_with_glyph_asset_id(glyph_asset_id: i32) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id,
tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 4,
height: 4,
tiles: vec![
Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id: 0 },
flip_x: false,
flip_y: false,
};
16
],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test]
fn hardware_can_render_scene_from_shared_scene_bank_pipeline() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(Arc::clone(&banks));
let scene = banks.scene_bank_slot(0).expect("scene bank slot 0 should be resident");
let mut cache = SceneViewportCache::new(&scene, 4, 4);
cache.materialize_all_layers(&scene);
let mut resolver = SceneViewportResolver::new(16, 16, 4, 4, 12, 20);
let update = resolver.update(&scene, 0, 0);
hardware.gfx.render_scene_from_cache(&cache, &update, &[0, 0, 0, 0]);
hardware.gfx.present();
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
}
#[test]
fn hardware_constructs_frame_composer_with_shared_scene_bank_access() {
let banks = Arc::new(MemoryBanks::new());
banks.install_scene_bank(2, Arc::new(make_scene()));
let hardware = Hardware::new_with_memory_banks(banks);
let scene = hardware
.frame_composer
.scene_bank_slot(2)
.expect("scene bank slot 2 should be resident");
assert_eq!(hardware.frame_composer.viewport_size(), (Hardware::W, Hardware::H));
assert_eq!(hardware.frame_composer.scene_bank_slot_count(), 16);
assert_eq!(scene.layers[0].tile_size, TileSize::Size8);
}
#[test]
fn publish_game2d_submission_applies_gfx2d_overlay_after_active_scene() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(banks);
let mut glyph_slots = [None; 16];
glyph_slots[0] = Some(0);
hardware.assets.glyph_asset_slot_index().rebuild_from_slots(&glyph_slots);
assert_eq!(
Game2DFrameComposer::bind_scene(&mut hardware, 0),
prometeu_hal::ComposerOpStatus::Ok
);
let mut packet = Game2DFrameComposer::close_game2d_packet(&hardware);
packet.gfx2d = vec![Gfx2dCommand::FillRect {
rect: Rect { x: 0, y: 0, w: 1, h: 1 },
color: Color::BLUE,
}];
RenderSubmissionSink::submit_render_submission(
&mut hardware,
RenderSubmission::game2d(FrameId::ZERO, packet),
)
.expect("local sink should publish Game2D packet");
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn publish_game2d_submission_uses_packet_scene_and_sprites_not_live_frame_state() {
let banks = Arc::new(MemoryBanks::new());
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(0, Arc::new(make_scene_with_glyph_asset_id(0)));
let mut hardware = Hardware::new_with_memory_banks(banks);
let mut glyph_slots = [None; 16];
glyph_slots[0] = Some(0);
hardware.assets.glyph_asset_slot_index().rebuild_from_slots(&glyph_slots);
assert_eq!(
Game2DFrameComposer::bind_scene(&mut hardware, 0),
prometeu_hal::ComposerOpStatus::Ok
);
let packet = Game2DFramePacket::new(
ComposerFramePacket {
bound_scene: Some(BoundScenePacket { bank_id: 0 }),
camera: Camera2D::default(),
sprites: vec![GameSpritePacket {
glyph_id: 0,
palette_id: 0,
x: 0,
y: 0,
layer: 0,
bank_id: 0,
flip_x: false,
flip_y: false,
priority: 0,
}],
hud: Default::default(),
},
Vec::new(),
);
Game2DFrameComposer::begin_frame(&mut hardware);
RenderSubmissionSink::submit_render_submission(
&mut hardware,
RenderSubmission::game2d(FrameId::ZERO, packet),
)
.expect("local sink should publish Game2D packet");
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
}
#[test]
fn render_submission_sink_accepts_owned_game2d_submission() {
let mut hardware = Hardware::new();
let packet = Game2DFramePacket::new(
ComposerFramePacket::default(),
vec![Gfx2dCommand::Clear { color: Color::BLUE }],
);
let submission = RenderSubmission::game2d(FrameId::ZERO, packet);
RenderSubmissionSink::submit_render_submission(&mut hardware, submission)
.expect("local sink should accept owned Game2D submissions");
assert_eq!(hardware.gfx.front_buffer()[0], Color::BLUE.raw());
}
#[test]
fn render_submission_sink_accepts_owned_shell_ui_submission() {
let mut hardware = Hardware::new();
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::Clear { color: Color::RED }]);
let submission = RenderSubmission::shell_ui(FrameId::ZERO, packet);
RenderSubmissionSink::submit_render_submission(&mut hardware, submission)
.expect("local sink should accept owned ShellUi submissions");
assert_eq!(hardware.gfx.front_buffer()[0], Color::RED.raw());
}
}

View File

@ -1,22 +0,0 @@
mod asset;
mod audio;
mod frame_composer;
mod gfx;
pub mod hardware;
mod local_render_backend;
mod memory_banks;
mod pad;
mod test_platform;
mod touch;
pub use crate::asset::AssetManager;
pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE};
pub use crate::frame_composer::{ComposerBuffer, FrameComposer, SceneStatus, SpriteController};
pub use crate::gfx::Gfx;
pub use crate::local_render_backend::LocalFramebufferRenderBackend;
pub use crate::memory_banks::{
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
};
pub use crate::pad::Pad;
pub use crate::test_platform::TestPlatform;

View File

@ -1,164 +0,0 @@
use crate::asset::GlyphAssetSlotIndex;
use crate::frame_composer::FrameComposer;
use crate::gfx::Gfx;
use crate::memory_banks::{GlyphBankPoolAccess, MemoryBanks, SceneBankPoolAccess};
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::{
OwnedRgba8888Frame, RenderSubmission, RenderSubmissionPacket, RenderWorkerBackend,
RenderWorkerError,
};
use std::sync::{Arc, Mutex};
pub struct LocalFramebufferRenderBackend {
gfx: Mutex<Gfx>,
frame_composer: Mutex<FrameComposer>,
}
impl LocalFramebufferRenderBackend {
pub fn new(width: usize, height: usize, glyph_banks: Arc<dyn GlyphBankPoolAccess>) -> Self {
Self::new_with_parts(
width,
height,
glyph_banks,
Arc::new(MemoryBanks::new()),
GlyphAssetSlotIndex::new(),
)
}
pub fn new_with_memory_banks(
width: usize,
height: usize,
memory_banks: Arc<MemoryBanks>,
glyph_slot_index: GlyphAssetSlotIndex,
) -> Self {
Self::new_with_parts(
width,
height,
Arc::clone(&memory_banks) as Arc<dyn GlyphBankPoolAccess>,
memory_banks,
glyph_slot_index,
)
}
fn new_with_parts(
width: usize,
height: usize,
glyph_banks: Arc<dyn GlyphBankPoolAccess>,
scene_banks: Arc<dyn SceneBankPoolAccess>,
glyph_slot_index: GlyphAssetSlotIndex,
) -> Self {
Self {
gfx: Mutex::new(Gfx::new(width, height, glyph_banks)),
frame_composer: Mutex::new(FrameComposer::new(
width,
height,
scene_banks,
glyph_slot_index,
)),
}
}
pub fn render_submission(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
let mut gfx = self.gfx.lock().map_err(|_| RenderWorkerError::InternalFailure)?;
match &submission.packet {
RenderSubmissionPacket::Game2D(packet) if submission.app_mode == AppMode::Game => {
if packet.composer.bound_scene.is_none() {
gfx.render_game2d_frame_packet(packet);
} else {
let mut frame_composer = self
.frame_composer
.lock()
.map_err(|_| RenderWorkerError::InternalFailure)?;
frame_composer.render_game2d_frame_packet(packet, &mut *gfx);
gfx.apply_gfx2d_commands(&packet.gfx2d);
}
}
RenderSubmissionPacket::ShellUi(packet) if submission.app_mode == AppMode::Shell => {
gfx.render_shell_ui_frame_packet(packet);
}
_ => return Err(RenderWorkerError::RenderFailed),
}
gfx.present();
let (width, height) = gfx.size();
OwnedRgba8888Frame::packed(
submission.frame_id,
submission.ownership,
width,
height,
gfx.front_buffer().to_vec(),
)
.map_err(|_| RenderWorkerError::InternalFailure)
}
}
impl RenderWorkerBackend for LocalFramebufferRenderBackend {
fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError> {
self.render_submission(submission)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_banks::MemoryBanks;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::color::Color;
use prometeu_hal::primitives::Rect;
use prometeu_hal::{
FrameId, Game2DFramePacket, Gfx2dCommand, GfxUiCommand, RenderOwnership, ShellUiFramePacket,
};
fn backend() -> LocalFramebufferRenderBackend {
let banks = Arc::new(MemoryBanks::new());
LocalFramebufferRenderBackend::new(4, 4, banks)
}
#[test]
fn local_backend_renders_game2d_submission_to_owned_frame() {
let backend = backend();
let packet = Game2DFramePacket {
gfx2d: vec![Gfx2dCommand::FillRect {
rect: Rect { x: 1, y: 1, w: 2, h: 2 },
color: Color::RED,
}],
..Default::default()
};
let ownership = RenderOwnership::new(3, AppMode::Game, 9);
let submission =
RenderSubmission::game2d(FrameId::new(5), packet).with_ownership(ownership);
let frame = backend.render(&submission).expect("game frame should render");
assert_eq!(frame.frame_id, FrameId::new(5));
assert_eq!(frame.ownership, ownership);
assert_eq!(frame.width, 4);
assert_eq!(frame.height, 4);
assert_eq!(frame.pixels[1 + 4], Color::RED.raw());
assert_eq!(frame.pixels[2 + 2 * 4], Color::RED.raw());
}
#[test]
fn local_backend_renders_shell_ui_submission_to_owned_frame() {
let backend = backend();
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::FillRect {
rect: Rect { x: 0, y: 0, w: 1, h: 1 },
color: Color::GREEN,
}]);
let ownership = RenderOwnership::new(4, AppMode::Shell, 10);
let submission =
RenderSubmission::shell_ui(FrameId::new(6), packet).with_ownership(ownership);
let frame = backend.render(&submission).expect("shell frame should render");
assert_eq!(frame.frame_id, FrameId::new(6));
assert_eq!(frame.ownership, ownership);
assert_eq!(frame.pixels[0], Color::GREEN.raw());
}
}

View File

@ -1,248 +0,0 @@
//! Memory bank access boundary.
//!
//! Render consumers must cross this boundary through small, read-only traits.
//! Frame packets carry resource IDs; large resident resources stay in banks and
//! are read in place through a narrow HAL callback contract. Installation and
//! slot replacement remain separate owner-side operations and are not part of
//! the render read surface.
use prometeu_hal::glyph_bank::GlyphBank;
use prometeu_hal::render_resources::RenderResourceAccess as HalRenderResourceAccess;
use prometeu_hal::scene_bank::SceneBank;
use prometeu_hal::sound_bank::SoundBank;
use std::sync::{Arc, RwLock};
/// Non-generic interface for peripherals to access graphical glyph banks.
pub trait GlyphBankPoolAccess: Send + Sync {
/// Returns a reference to the resident GlyphBank in the specified slot, if any.
fn glyph_bank_slot(&self, slot: usize) -> Option<Arc<GlyphBank>>;
/// Returns the total number of slots available in this bank.
fn glyph_bank_slot_count(&self) -> usize;
}
/// Non-generic interface for the AssetManager to install graphical glyph banks.
pub trait GlyphBankPoolInstaller: Send + Sync {
/// Atomically swaps the resident GlyphBank in the specified slot.
fn install_glyph_bank(&self, slot: usize, bank: Arc<GlyphBank>);
/// Clears all resident GlyphBank slots.
fn clear_glyph_banks(&self);
}
/// Non-generic interface for peripherals to access sound banks.
pub trait SoundBankPoolAccess: Send + Sync {
/// Returns a reference to the resident SoundBank in the specified slot, if any.
fn sound_bank_slot(&self, slot: usize) -> Option<Arc<SoundBank>>;
/// Returns the total number of slots available in this bank.
fn sound_bank_slot_count(&self) -> usize;
}
/// Non-generic interface for the AssetManager to install sound banks.
pub trait SoundBankPoolInstaller: Send + Sync {
/// Atomically swaps the resident SoundBank in the specified slot.
fn install_sound_bank(&self, slot: usize, bank: Arc<SoundBank>);
/// Clears all resident SoundBank slots.
fn clear_sound_banks(&self);
}
/// Non-generic interface for peripherals to access scene banks.
pub trait SceneBankPoolAccess: Send + Sync {
/// Returns a reference to the resident SceneBank in the specified slot, if any.
fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>>;
/// Returns the total number of slots available in this bank.
fn scene_bank_slot_count(&self) -> usize;
}
/// Non-generic interface for the AssetManager to install scene banks.
pub trait SceneBankPoolInstaller: Send + Sync {
/// Atomically swaps the resident SceneBank in the specified slot.
fn install_scene_bank(&self, slot: usize, bank: Arc<SceneBank>);
/// Clears all resident SceneBank slots.
fn clear_scene_banks(&self);
}
/// Centralized container for all hardware memory banks.
///
/// MemoryBanks represent the actual hardware slot state.
/// Peripherals consume this state via narrow, non-generic traits.
/// AssetManager coordinates residency and installs assets into these slots.
pub struct MemoryBanks {
glyph_bank_pool: Arc<RwLock<[Option<Arc<GlyphBank>>; 16]>>,
sound_bank_pool: Arc<RwLock<[Option<Arc<SoundBank>>; 16]>>,
scene_bank_pool: Arc<RwLock<[Option<Arc<SceneBank>>; 16]>>,
}
impl Default for MemoryBanks {
fn default() -> Self {
Self::new()
}
}
impl MemoryBanks {
/// Creates a new set of memory banks with empty slots.
pub fn new() -> Self {
Self {
glyph_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
sound_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
scene_bank_pool: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
}
}
}
impl GlyphBankPoolAccess for MemoryBanks {
fn glyph_bank_slot(&self, slot: usize) -> Option<Arc<GlyphBank>> {
let pool = self.glyph_bank_pool.read().unwrap();
pool.get(slot).and_then(|s| s.as_ref().map(Arc::clone))
}
fn glyph_bank_slot_count(&self) -> usize {
16
}
}
impl GlyphBankPoolInstaller for MemoryBanks {
fn install_glyph_bank(&self, slot: usize, bank: Arc<GlyphBank>) {
let mut pool = self.glyph_bank_pool.write().unwrap();
if slot < 16 {
pool[slot] = Some(bank);
}
}
fn clear_glyph_banks(&self) {
self.glyph_bank_pool.write().unwrap().fill(None);
}
}
impl SoundBankPoolAccess for MemoryBanks {
fn sound_bank_slot(&self, slot: usize) -> Option<Arc<SoundBank>> {
let pool = self.sound_bank_pool.read().unwrap();
pool.get(slot).and_then(|s| s.as_ref().map(Arc::clone))
}
fn sound_bank_slot_count(&self) -> usize {
16
}
}
impl SoundBankPoolInstaller for MemoryBanks {
fn install_sound_bank(&self, slot: usize, bank: Arc<SoundBank>) {
let mut pool = self.sound_bank_pool.write().unwrap();
if slot < 16 {
pool[slot] = Some(bank);
}
}
fn clear_sound_banks(&self) {
self.sound_bank_pool.write().unwrap().fill(None);
}
}
impl SceneBankPoolAccess for MemoryBanks {
fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>> {
let pool = self.scene_bank_pool.read().unwrap();
pool.get(slot).and_then(|s| s.as_ref().map(Arc::clone))
}
fn scene_bank_slot_count(&self) -> usize {
16
}
}
impl SceneBankPoolInstaller for MemoryBanks {
fn install_scene_bank(&self, slot: usize, bank: Arc<SceneBank>) {
let mut pool = self.scene_bank_pool.write().unwrap();
if slot < 16 {
pool[slot] = Some(bank);
}
}
fn clear_scene_banks(&self) {
self.scene_bank_pool.write().unwrap().fill(None);
}
}
impl HalRenderResourceAccess for MemoryBanks {
fn with_glyph_bank<R>(&self, bank_id: usize, read: impl FnOnce(&GlyphBank) -> R) -> Option<R> {
let pool = self.glyph_bank_pool.read().unwrap();
let bank = pool.get(bank_id)?.as_deref()?;
Some(read(bank))
}
fn glyph_bank_count(&self) -> usize {
16
}
fn with_scene_bank<R>(&self, bank_id: usize, read: impl FnOnce(&SceneBank) -> R) -> Option<R> {
let pool = self.scene_bank_pool.read().unwrap();
let bank = pool.get(bank_id)?.as_deref()?;
Some(read(bank))
}
fn scene_bank_count(&self) -> usize {
16
}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_hal::color::Color;
use prometeu_hal::glyph::Glyph;
use prometeu_hal::glyph_bank::TileSize;
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
use prometeu_hal::tile::Tile;
use prometeu_hal::tilemap::TileMap;
fn assert_send_sync<T: Send + Sync>() {}
fn make_glyph_bank() -> GlyphBank {
let mut bank = GlyphBank::new(TileSize::Size8, 8, 8);
bank.palettes[0][1] = Color::WHITE;
bank
}
fn make_scene_bank() -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id: 0,
tile_size: TileSize::Size8,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 1,
height: 1,
tiles: vec![Tile {
active: true,
glyph: Glyph { glyph_id: 0, palette_id: 0 },
flip_x: false,
flip_y: false,
}],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test]
fn render_resource_access_traits_are_thread_safe() {
assert_send_sync::<MemoryBanks>();
assert_send_sync::<Arc<dyn GlyphBankPoolAccess>>();
assert_send_sync::<Arc<dyn SceneBankPoolAccess>>();
}
#[test]
fn render_resource_access_exposes_read_only_glyph_and_scene_banks_without_arc_contract() {
let banks = MemoryBanks::new();
banks.install_glyph_bank(0, Arc::new(make_glyph_bank()));
banks.install_scene_bank(1, Arc::new(make_scene_bank()));
let glyph_tile_size =
HalRenderResourceAccess::with_glyph_bank(&banks, 0, |bank| bank.tile_size);
let scene_tile_size =
HalRenderResourceAccess::with_scene_bank(&banks, 1, |bank| bank.layers[0].tile_size);
assert_eq!(glyph_tile_size, Some(TileSize::Size8));
assert_eq!(scene_tile_size, Some(TileSize::Size8));
assert_eq!(HalRenderResourceAccess::with_glyph_bank(&banks, 15, |_| ()), None);
assert_eq!(HalRenderResourceAccess::with_scene_bank(&banks, 15, |_| ()), None);
assert_eq!(HalRenderResourceAccess::glyph_bank_count(&banks), 16);
assert_eq!(HalRenderResourceAccess::scene_bank_count(&banks), 16);
}
}

View File

@ -1,100 +0,0 @@
use crate::hardware::Hardware;
use crate::memory_banks::MemoryBanks;
use prometeu_hal::{
AssetBridge, AudioBridge, Game2DFrameComposer, InputPlatform, RenderBackend,
RenderSubmissionSink, RuntimePlatform, TelemetryPlatform,
};
use std::sync::Arc;
pub struct TestPlatform {
hardware: Hardware,
}
impl Default for TestPlatform {
fn default() -> Self {
Self::new()
}
}
impl TestPlatform {
pub fn new() -> Self {
Self { hardware: Hardware::new() }
}
pub fn new_with_memory_banks(memory_banks: Arc<MemoryBanks>) -> Self {
Self { hardware: Hardware::new_with_memory_banks(memory_banks) }
}
pub fn local_hardware(&self) -> &Hardware {
&self.hardware
}
pub fn local_hardware_mut(&mut self) -> &mut Hardware {
&mut self.hardware
}
}
impl RuntimePlatform for TestPlatform {
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink {
self.hardware.render_submission_sink()
}
fn render_backend(&mut self) -> &mut dyn RenderBackend {
self.hardware.render_backend()
}
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer {
self.hardware.game2d_frame_composer()
}
fn audio(&self) -> &dyn AudioBridge {
self.hardware.audio()
}
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
self.hardware.audio_mut()
}
fn input(&self) -> &dyn InputPlatform {
self.hardware.input()
}
fn input_mut(&mut self) -> &mut dyn InputPlatform {
self.hardware.input_mut()
}
fn assets(&self) -> &dyn AssetBridge {
self.hardware.assets()
}
fn assets_mut(&mut self) -> &mut dyn AssetBridge {
self.hardware.assets_mut()
}
fn telemetry(&self) -> &dyn TelemetryPlatform {
self.hardware.telemetry()
}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_hal::{FrameId, Game2DFramePacket, RenderSubmission};
#[test]
fn test_platform_initializes_required_facades() {
let mut platform = TestPlatform::new();
platform.game2d_frame_composer().begin_frame();
platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::game2d(
FrameId::ZERO,
Game2DFramePacket::default(),
))
.expect("test platform render sink should accept local submission");
assert!(!platform.audio().is_playing(0));
assert!(!platform.input().pad().any());
assert!(platform.telemetry().telemetry_snapshot().is_none());
}
}

View File

@ -1,33 +0,0 @@
use prometeu_hal::button::Button;
use prometeu_hal::{InputSignals, TouchBridge};
#[derive(Default, Clone, Copy, Debug)]
pub struct Touch {
pub f: Button,
pub x: i32,
pub y: i32,
}
impl Touch {
/// Transient flags should last only 1 frame.
pub fn begin_frame(&mut self, signals: &InputSignals) {
self.f.begin_frame(signals.f_signal);
self.x = signals.x_pos;
self.y = signals.y_pos;
}
}
impl TouchBridge for Touch {
fn begin_frame(&mut self, signals: &InputSignals) {
self.begin_frame(signals)
}
fn f(&self) -> &Button {
&self.f
}
fn x(&self) -> i32 {
self.x
}
fn y(&self) -> i32 {
self.y
}
}

View File

@ -1,14 +0,0 @@
[package]
name = "prometeu-firmware"
version = "0.1.0"
edition = "2024"
license.workspace = true
[dependencies]
prometeu-vm = { path = "../prometeu-vm" }
prometeu-system = { path = "../prometeu-system" }
prometeu-hal = { path = "../prometeu-hal" }
[dev-dependencies]
prometeu-drivers = { path = "../prometeu-drivers" }
prometeu-bytecode = { path = "../prometeu-bytecode" }

View File

@ -1,10 +0,0 @@
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum BootTarget {
#[default]
Hub,
Cartridge {
path: String,
debug: bool,
debug_port: u16,
},
}

File diff suppressed because it is too large Load Diff

View File

@ -1,68 +0,0 @@
use crate::firmware::firmware_state::{FirmwareState, LaunchHubStep};
use crate::firmware::prometeu_context::PrometeuContext;
use prometeu_hal::color::Color;
use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_hal::{FrameId, GfxUiCommand, RenderSubmission, ShellUiFramePacket};
use prometeu_system::CrashReport;
#[derive(Debug, Clone)]
pub struct AppCrashesStep {
pub report: CrashReport,
}
impl AppCrashesStep {
pub fn log_message(&self) -> String {
format!("App Crashed: {}", self.report)
}
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.log(LogLevel::Error, LogSource::Pos, self.report.log_tag(), self.log_message());
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
// Update peripherals for input on the crash screen
ctx.platform.input_mut().pad_mut().begin_frame(ctx.signals);
let packet = ShellUiFramePacket::new(vec![GfxUiCommand::Clear { color: Color::RED }]);
ctx.platform
.render_submission_sink()
.submit_render_submission(RenderSubmission::shell_ui(FrameId::ZERO, packet))
.expect("firmware crash render submission should publish");
// If START is pressed, return to the Hub
if ctx.platform.input().pad().start().down {
return Some(FirmwareState::LaunchHub(LaunchHubStep));
}
None
}
pub fn on_exit(&mut self, _ctx: &mut PrometeuContext) {}
}
#[cfg(test)]
mod tests {
use super::*;
use prometeu_bytecode::TrapInfo;
#[test]
fn crash_step_formats_trap_from_structured_report() {
let step = AppCrashesStep {
report: CrashReport::VmTrap {
trap: TrapInfo {
code: 0xAB,
opcode: 0x22,
message: "type mismatch".into(),
pc: 0x44,
span: None,
},
},
};
let msg = step.log_message();
assert!(msg.contains("PVM Trap 0x000000AB"));
assert!(msg.contains("PC 0x44"));
assert!(msg.contains("opcode 0x0022"));
assert!(msg.contains("type mismatch"));
}
}

View File

@ -1,73 +0,0 @@
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep};
use crate::firmware::prometeu_context::PrometeuContext;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_system::task::{TaskId, TaskState};
use prometeu_system::{CrashReport, ResidentGameState};
#[derive(Debug, Clone)]
pub struct GameRunningStep {
pub task_id: TaskId,
}
impl GameRunningStep {
pub fn new(task_id: TaskId) -> Self {
Self { task_id }
}
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.log(LogLevel::Info, LogSource::Pos, 0, "Entering GameRunning".to_string());
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
let Some(task_state) = ctx.os.lifecycle().task_state(self.task_id) else {
// TODO: is it panic?
let report =
CrashReport::VmPanic { message: "task is gone, panic!".to_string(), pc: None };
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
};
if task_state != TaskState::Foreground {
// TODO: is it panic?
let report = CrashReport::VmPanic {
message: "should be running as foreground, panic!".to_string(),
pc: None,
};
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
let pause_requested = matches!(
ctx.os.lifecycle().resident_game().map(|game| game.state),
Some(ResidentGameState::PauseRequested { .. })
);
let result = ctx.os.vm().tick_session(self.task_id, ctx.signals, ctx.platform);
if let Some(report) = result {
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
if pause_requested {
match ctx.os.lifecycle().advance_game_pause_budget(self.task_id) {
Ok(true) => {
ctx.os.vm().transition_render_owner(AppMode::Shell, 0);
return Some(FirmwareState::HubHome(HubHomeStep));
}
Ok(false) => {}
Err(_) => {
let report = CrashReport::VmPanic {
message: "failed to advance game pause budget".to_string(),
pc: None,
};
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
}
}
None
}
pub fn on_exit(&mut self, _ctx: &mut PrometeuContext) {}
}

View File

@ -1,128 +0,0 @@
use crate::firmware::firmware_state::{
AppCrashesStep, FirmwareState, LoadCartridgeStep, ShellRunningStep,
};
use crate::firmware::prometeu_context::PrometeuContext;
use crate::firmware::resident_game_termination;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::cartridge_loader::CartridgeLoader;
use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_hal::primitives::Rect;
use prometeu_system::SystemProfileAction;
use prometeu_system::windows::WindowOwner;
#[derive(Debug, Clone)]
pub struct HubHomeStep;
impl HubHomeStep {
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.log(LogLevel::Info, LogSource::Hub, 0, "Entering HubHome".to_string());
ctx.os.windows().remove_all_windows();
ctx.hub.init();
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
let outcome = ctx.hub.update_shell_profile(ctx.os, ctx.platform);
if let Some(report) = outcome.crash {
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
match outcome.action {
Some(SystemProfileAction::LaunchNativeShell(app)) => {
let task_id = match ctx
.os
.sessions()
.try_create_native_shell_task(app.app_id(), app.title())
{
Ok(task_id) => task_id,
Err(error) => {
ctx.os.log(
LogLevel::Warn,
LogSource::Hub,
0,
format!("Failed to launch native shell {}: {:?}", app.title(), error),
);
return None;
}
};
let id = ctx.os.windows().add_window(
app.title().to_string(),
WindowOwner::Task(task_id),
Rect { x: 40, y: 20, w: 400, h: 220 },
app.color(),
);
ctx.os.windows().set_focus(id);
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
}
Some(SystemProfileAction::LaunchGame { target }) => {
match CartridgeLoader::load(&target.path) {
Ok(cartridge) => {
if cartridge.app_mode == AppMode::Game
&& let Some(resident_task_id) = ctx.os.lifecycle().resident_game_task()
{
let resident_app_id = ctx
.os
.sessions()
.vm_session_for_task(resident_task_id)
.map(|session| session.app_id)
.unwrap_or(0);
if resident_app_id == cartridge.app_id {
return Some(FirmwareState::ResumeResidentGame);
}
if let Err(error) = resident_game_termination::terminate_resident_game(
ctx.os,
ctx.platform,
false,
) {
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!(
"Failed to terminate resident game app_id {} before launching {}: {:?}",
resident_app_id, cartridge.title, error
),
);
return None;
}
}
return Some(FirmwareState::LoadCartridge(
LoadCartridgeStep::new_game_switch(cartridge),
));
}
Err(error) => {
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!(
"Failed to launch Home game {}: {:?}",
target.path.display(),
error
),
);
}
}
}
Some(SystemProfileAction::KillResidentGame) => {
if let Err(error) =
resident_game_termination::terminate_resident_game(ctx.os, ctx.platform, true)
{
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!("Failed to kill resident game from Hub: {error:?}"),
);
}
}
Some(SystemProfileAction::CloseShell) | None => {}
}
None
}
pub fn on_exit(&mut self, _ctx: &mut PrometeuContext) {}
}

View File

@ -1,135 +0,0 @@
use crate::firmware::firmware_state::{
AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep, ShellRunningStep,
};
use crate::firmware::prometeu_context::PrometeuContext;
use crate::firmware::resident_game_termination;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::cartridge::Cartridge;
use prometeu_hal::color::Color;
use prometeu_hal::log::{LogLevel, LogSource};
use prometeu_hal::primitives::Rect;
use prometeu_system::CrashReport;
use prometeu_system::windows::WindowOwner;
#[derive(Debug, Clone)]
pub struct LoadCartridgeStep {
pub cartridge: Cartridge,
init_error: Option<CrashReport>,
loaded_task_id: Option<prometeu_system::task::TaskId>,
loaded_app_mode: Option<AppMode>,
failure_recovery: LoadCartridgeFailureRecovery,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoadCartridgeFailureRecovery {
AppCrash,
ReturnToHub,
}
impl LoadCartridgeStep {
pub fn new(cartridge: Cartridge) -> Self {
Self {
cartridge,
init_error: None,
loaded_task_id: None,
loaded_app_mode: None,
failure_recovery: LoadCartridgeFailureRecovery::AppCrash,
}
}
pub fn new_game_switch(cartridge: Cartridge) -> Self {
Self { failure_recovery: LoadCartridgeFailureRecovery::ReturnToHub, ..Self::new(cartridge) }
}
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.log(
LogLevel::Info,
LogSource::Pos,
0,
format!("Loading cartridge: {}", self.cartridge.title),
);
ctx.platform.game2d_frame_composer().unbind_scene();
// Initialize Asset Manager
ctx.platform.assets_mut().initialize_for_cartridge(
self.cartridge.asset_table.clone(),
self.cartridge.preload.clone(),
self.cartridge.assets.clone(),
);
match ctx.os.sessions().load_vm_cartridge(&self.cartridge) {
Ok(loaded) => {
self.loaded_task_id = Some(loaded.task_id);
self.loaded_app_mode = Some(loaded.app_mode);
}
Err(report) => {
self.init_error = Some(report.into_crash_report("load VM cartridge into session"));
}
}
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
if let Some(report) = self.init_error.take() {
if self.failure_recovery == LoadCartridgeFailureRecovery::ReturnToHub {
if self.cartridge.app_mode == AppMode::Game
&& let Some(task_id) = ctx.os.lifecycle().resident_game_task()
{
let is_partial_target = ctx
.os
.sessions()
.vm_session_for_task(task_id)
.map(|session| session.app_id == self.cartridge.app_id)
.unwrap_or(false);
if is_partial_target {
let _ = resident_game_termination::terminate_resident_game(
ctx.os,
ctx.platform,
false,
);
}
}
ctx.os.log(
LogLevel::Error,
LogSource::Hub,
0,
format!(
"Failed to switch to game {} app_id {}; returning to Home: {:?}",
self.cartridge.title, self.cartridge.app_id, report
),
);
return Some(FirmwareState::HubHome(HubHomeStep));
}
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
let Some(task_id) = self.loaded_task_id.take() else {
let report = CrashReport::VmPanic {
message: format!(
"cartridge {} was not loaded into a VM session",
self.cartridge.title
),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
};
if self.loaded_app_mode == Some(AppMode::Shell) {
let id = ctx.os.windows().add_window(
self.cartridge.title.clone(),
WindowOwner::Task(task_id),
Rect { x: 40, y: 20, w: 240, h: 140 },
Color::WHITE,
);
ctx.os.windows().set_focus(id);
return Some(FirmwareState::ShellRunning(ShellRunningStep::new(task_id)));
}
Some(FirmwareState::GameRunning(GameRunningStep::new(task_id)))
}
pub fn on_exit(&mut self, _ctx: &mut PrometeuContext) {}
}

View File

@ -1,131 +0,0 @@
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState, HubHomeStep};
use crate::firmware::prometeu_context::PrometeuContext;
use prometeu_hal::log::LogSource;
use prometeu_system::process::ProcessKind;
use prometeu_system::task::{TaskId, TaskState};
use prometeu_system::{CrashReport, SystemProfileAction};
#[derive(Debug, Clone)]
pub struct ShellRunningStep {
pub task_id: TaskId,
}
impl ShellRunningStep {
pub fn new(task_id: TaskId) -> Self {
Self { task_id }
}
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
ctx.os.info(LogSource::Hub, "Entering ShellRunning".to_string());
}
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
let Some(task_state) = ctx.os.lifecycle().task_state(self.task_id) else {
let report = CrashReport::VmPanic {
message: format!("ShellRunningStep has no task for {:?}", self.task_id),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
};
match task_state {
TaskState::Foreground => {
// Continue below.
}
TaskState::Closed => {
return Some(FirmwareState::HubHome(HubHomeStep));
}
TaskState::Crashed => {
let report = CrashReport::VmPanic {
message: format!("Shell task {:?} is already crashed", self.task_id),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
TaskState::Suspended | TaskState::Background => {
let report = CrashReport::VmPanic {
message: format!(
"ShellRunningStep expected foreground task {:?}, got {:?}",
self.task_id, task_state,
),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
}
let process_kind = match ctx.os.lifecycle().process_kind_for_task(self.task_id) {
Ok(process_kind) => process_kind,
Err(error) => {
let report = CrashReport::VmPanic {
message: format!(
"ShellRunningStep cannot resolve process for {:?}: {:?}",
self.task_id, error,
),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
};
let outcome = match process_kind {
ProcessKind::VmShell => {
ctx.hub.update_vm_shell_profile(ctx.os, self.task_id, ctx.signals, ctx.platform)
}
ProcessKind::NativeShell => ctx.hub.update_native_shell_profile(ctx.os, ctx.platform),
ProcessKind::VmGame => {
let report = CrashReport::VmPanic {
message: format!(
"ShellRunningStep expected shell process for {:?}, got VmGame",
self.task_id,
),
pc: None,
};
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
};
if let Some(report) = outcome.crash {
let _ = ctx.os.lifecycle().crash_task(self.task_id, Some(&report));
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
}
if outcome.action == Some(SystemProfileAction::CloseShell) {
self.close_current_shell(ctx);
return Some(FirmwareState::HubHome(HubHomeStep));
}
if !ctx.os.windows().focused_window_belongs_to_task(self.task_id) {
ctx.os.info(
LogSource::Hub,
format!(
"Closing shell task {:?}: focused window does not belong to task",
self.task_id,
),
);
self.close_current_shell(ctx);
return Some(FirmwareState::HubHome(HubHomeStep));
}
None
}
fn close_current_shell(&mut self, ctx: &mut PrometeuContext) {
if let Err(error) = ctx.os.lifecycle().close_task(self.task_id) {
ctx.os.error(
LogSource::Hub,
format!("Failed to close shell task {:?}: {:?}", self.task_id, error),
);
}
}
pub fn on_exit(&mut self, ctx: &mut PrometeuContext) {
ctx.os.info(LogSource::Hub, "Exiting ShellRunning".to_string());
}
}

View File

@ -1,11 +0,0 @@
use crate::firmware::boot_target::BootTarget;
use prometeu_hal::{InputSignals, RuntimePlatform};
use prometeu_system::{PrometeuHub, SystemOS};
pub struct PrometeuContext<'a> {
pub os: &'a mut SystemOS,
pub hub: &'a mut PrometeuHub,
pub boot_target: &'a BootTarget,
pub signals: &'a InputSignals,
pub platform: &'a mut dyn RuntimePlatform,
}

View File

@ -1,42 +0,0 @@
use prometeu_hal::RuntimePlatform;
use prometeu_hal::app_mode::AppMode;
use prometeu_hal::log::LogSource;
use prometeu_system::task::TaskId;
use prometeu_system::{LifecycleError, SystemOS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResidentGameTerminationOutcome {
NoResidentGame,
Terminated { task_id: TaskId, title: String },
}
pub(crate) fn terminate_resident_game(
os: &mut SystemOS,
platform: &mut dyn RuntimePlatform,
log_manual_hub_kill: bool,
) -> Result<ResidentGameTerminationOutcome, LifecycleError> {
let Some(task_id) = os.lifecycle().resident_game_task() else {
return Ok(ResidentGameTerminationOutcome::NoResidentGame);
};
let title = os
.sessions()
.vm_session_for_task(task_id)
.map(|session| session.title.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let terminated = os.lifecycle().terminate_resident_game()?;
let Some(task_id) = terminated else {
return Ok(ResidentGameTerminationOutcome::NoResidentGame);
};
os.vm().transition_render_owner(AppMode::Shell, 0);
platform.game2d_frame_composer().unbind_scene();
platform.assets_mut().shutdown();
if log_manual_hub_kill {
os.info(LogSource::Hub, format!("Resident game killed from Hub: {title}"));
}
Ok(ResidentGameTerminationOutcome::Terminated { task_id, title })
}

View File

@ -1,3 +0,0 @@
pub mod firmware;
pub use firmware::*;

View File

@ -1,7 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum AppMode {
Game,
Shell,
}

View File

@ -1,203 +0,0 @@
use serde::{Deserialize, Serialize};
pub type HandleId = u32;
pub type AssetId = i32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[allow(non_camel_case_types)]
pub enum BankType {
GLYPH,
SOUNDS,
SCENE,
// TILEMAPS,
// BLOBS,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum AssetCodec {
#[serde(rename = "NONE")]
None,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AssetEntry {
pub asset_id: AssetId,
pub asset_name: String,
pub bank_type: BankType,
pub offset: u64,
pub size: u64,
pub decoded_size: u64,
pub codec: AssetCodec,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GlyphBankMetadata {
pub tile_size: u32,
pub width: u32,
pub height: u32,
pub palette_count: u32,
pub palette_authored: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SoundBankMetadata {
pub sample_rate: u32,
pub channels: u32,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SceneBankMetadata {}
pub const SCENE_PAYLOAD_MAGIC_V1: [u8; 4] = *b"SCNE";
pub const SCENE_PAYLOAD_VERSION_V1: u16 = 1;
pub const SCENE_LAYER_COUNT_V1: usize = 4;
pub const SCENE_HEADER_BYTES_V1: usize = 12;
// Per-layer header:
// flags:u8 + glyph_asset_id:i32 + tile_size:u8 + reserved:u16
// + parallax_x:f32 + parallax_y:f32 + width:u32 + height:u32
// + tile_count:u32 + reserved:u32
pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 32;
pub const SCENE_TILE_RECORD_BYTES_V1: usize = 4;
pub const SCENE_DECODED_LAYER_OVERHEAD_BYTES_V1: usize = 16;
impl AssetEntry {
pub fn metadata_as_glyph_bank(&self) -> Result<GlyphBankMetadata, String> {
if self.bank_type != BankType::GLYPH {
return Err(format!(
"Asset {} is not a GLYPH bank (type: {:?})",
self.asset_id, self.bank_type
));
}
serde_json::from_value(self.metadata.clone())
.map_err(|e| format!("Invalid GLYPH metadata for asset {}: {}", self.asset_id, e))
}
pub fn metadata_as_sound_bank(&self) -> Result<SoundBankMetadata, String> {
if self.bank_type != BankType::SOUNDS {
return Err(format!(
"Asset {} is not a SOUNDS bank (type: {:?})",
self.asset_id, self.bank_type
));
}
serde_json::from_value(self.metadata.clone())
.map_err(|e| format!("Invalid SOUNDS metadata for asset {}: {}", self.asset_id, e))
}
pub fn metadata_as_scene_bank(&self) -> Result<SceneBankMetadata, String> {
if self.bank_type != BankType::SCENE {
return Err(format!(
"Asset {} is not a SCENE bank (type: {:?})",
self.asset_id, self.bank_type
));
}
serde_json::from_value(self.metadata.clone())
.map_err(|e| format!("Invalid SCENE metadata for asset {}: {}", self.asset_id, e))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PreloadEntry {
pub asset_id: AssetId,
pub slot: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(i32)]
pub enum LoadStatus {
PENDING = 0,
LOADING = 1,
READY = 2,
COMMITTED = 3,
CANCELED = 4,
ERROR = 5,
UnknownHandle = 6,
QUEUED = 7,
ACTIVE = 8,
SUPERSEDED = 9,
EMPTY = 10,
INVALID = 11,
BackendUnavailable = 12,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum AssetLoadError {
AssetNotFound = 3,
SlotKindMismatch = 4,
SlotIndexInvalid = 5,
BackendError = 6,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum AssetOpStatus {
Ok = 0,
UnknownHandle = 1,
InvalidState = 2,
Superseded = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetBacklogInfo {
pub status: AssetOpStatus,
pub pending_count: u32,
pub active_handle: HandleId,
pub active_asset_id: Option<AssetId>,
pub active_bank_type: Option<BankType>,
pub active_slot: Option<usize>,
pub active_progress: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetBacklogPosition {
pub status: AssetOpStatus,
pub state: LoadStatus,
pub position: u32,
pub progress: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetTargetStatus {
pub status: AssetOpStatus,
pub asset_id: Option<AssetId>,
pub handle: HandleId,
pub state: LoadStatus,
pub position: u32,
pub progress: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BankTelemetry {
pub bank_type: BankType,
pub used_slots: usize,
pub total_slots: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlotStats {
pub asset_id: Option<AssetId>,
pub asset_name: Option<String>,
pub generation: u32,
pub resident_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SlotRef {
pub asset_type: BankType,
pub index: usize,
}
impl SlotRef {
pub fn gfx(index: usize) -> Self {
Self { asset_type: BankType::GLYPH, index }
}
pub fn audio(index: usize) -> Self {
Self { asset_type: BankType::SOUNDS, index }
}
pub fn scene(index: usize) -> Self {
Self { asset_type: BankType::SCENE, index }
}
}

View File

@ -1,29 +0,0 @@
use crate::asset::{
AssetBacklogInfo, AssetBacklogPosition, AssetEntry, AssetId, AssetLoadError, AssetOpStatus,
AssetTargetStatus, BankTelemetry, BankType, HandleId, LoadStatus, PreloadEntry, SlotRef,
SlotStats,
};
use crate::cartridge::AssetsPayloadSource;
pub trait AssetBridge {
fn initialize_for_cartridge(
&self,
assets: Vec<AssetEntry>,
preload: Vec<PreloadEntry>,
assets_data: AssetsPayloadSource,
);
fn load(&self, asset_id: AssetId, slot_index: usize) -> Result<HandleId, AssetLoadError>;
fn status(&self, handle: HandleId) -> LoadStatus;
fn commit(&self, handle: HandleId) -> AssetOpStatus;
fn cancel(&self, handle: HandleId) -> AssetOpStatus;
fn backlog_info(&self) -> AssetBacklogInfo;
fn backlog_position(&self, handle: HandleId) -> AssetBacklogPosition;
fn backlog_move(&self, handle: HandleId, new_position: usize) -> AssetOpStatus;
fn backlog_promote(&self, handle: HandleId) -> AssetOpStatus;
fn backlog_demote(&self, handle: HandleId) -> AssetOpStatus;
fn target_status(&self, bank_type: BankType, slot: usize) -> AssetTargetStatus;
fn apply_commits(&self);
fn bank_telemetry(&self) -> Vec<BankTelemetry>;
fn slot_info(&self, slot: SlotRef) -> SlotStats;
fn shutdown(&self);
}

View File

@ -1,52 +0,0 @@
use crate::sample::Sample;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoopMode {
Off,
On,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum AudioOpStatus {
Ok = 0,
VoiceInvalid = 1,
SampleNotFound = 2,
ArgRangeInvalid = 3,
AssetNotFound = 4,
NoEffect = 5,
BankInvalid = 6,
}
pub trait AudioBridge {
#[allow(clippy::too_many_arguments)]
fn play(
&mut self,
bank_id: u8,
sample_id: u16,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: LoopMode,
) -> AudioOpStatus;
#[allow(clippy::too_many_arguments)]
fn play_sample(
&mut self,
sample: Arc<Sample>,
voice_id: usize,
volume: u8,
pan: u8,
pitch: f64,
priority: u8,
loop_mode: LoopMode,
) -> AudioOpStatus;
fn stop(&mut self, voice_id: usize);
fn set_volume(&mut self, voice_id: usize, volume: u8);
fn set_pan(&mut self, voice_id: usize, pan: u8);
fn set_pitch(&mut self, voice_id: usize, pitch: f64);
fn is_playing(&self, voice_id: usize) -> bool;
fn clear_commands(&mut self);
}

View File

@ -1,310 +0,0 @@
use crate::app_mode::AppMode;
use crate::asset::{AssetEntry, PreloadEntry};
use crate::syscalls::CapFlags;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{self, Cursor, Read, Seek, SeekFrom};
use std::path::PathBuf;
use std::sync::Arc;
pub const ASSETS_PA_MAGIC: [u8; 4] = *b"ASPA";
pub const ASSETS_PA_SCHEMA_VERSION: u32 = 1;
pub const ASSETS_PA_PRELUDE_SIZE: usize = 32;
#[derive(Debug, Clone)]
pub struct Cartridge {
pub app_id: u32,
pub title: String,
pub app_version: String,
pub app_mode: AppMode,
pub capabilities: CapFlags,
pub program: Vec<u8>,
pub assets: AssetsPayloadSource,
pub asset_table: Vec<AssetEntry>,
pub preload: Vec<PreloadEntry>,
}
#[derive(Debug, Clone)]
pub struct CartridgeDTO {
pub app_id: u32,
pub title: String,
pub app_version: String,
pub app_mode: AppMode,
pub capabilities: CapFlags,
pub program: Vec<u8>,
pub assets: AssetsPayloadSource,
pub asset_table: Vec<AssetEntry>,
pub preload: Vec<PreloadEntry>,
}
impl From<CartridgeDTO> for Cartridge {
fn from(dto: CartridgeDTO) -> Self {
Self {
app_id: dto.app_id,
title: dto.title,
app_version: dto.app_version,
app_mode: dto.app_mode,
capabilities: dto.capabilities,
program: dto.program,
assets: dto.assets,
asset_table: dto.asset_table,
preload: dto.preload,
}
}
}
#[derive(Debug, Clone)]
pub enum AssetsPayloadSource {
Memory(Arc<[u8]>),
File(Arc<FileAssetsPayloadSource>),
}
impl AssetsPayloadSource {
pub fn empty() -> Self {
Self::Memory(Arc::<[u8]>::from(Vec::<u8>::new()))
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self::Memory(Arc::<[u8]>::from(bytes))
}
pub fn from_file(path: PathBuf, payload_offset: u64, payload_len: u64) -> Self {
Self::File(Arc::new(FileAssetsPayloadSource { path, payload_offset, payload_len }))
}
pub fn is_empty(&self) -> bool {
match self {
Self::Memory(bytes) => bytes.is_empty(),
Self::File(source) => source.payload_len == 0,
}
}
pub fn open_slice(&self, offset: u64, size: u64) -> io::Result<AssetsPayloadSlice> {
match self {
Self::Memory(bytes) => {
let start =
usize::try_from(offset).map_err(|_| invalid_input("asset offset overflow"))?;
let len =
usize::try_from(size).map_err(|_| invalid_input("asset size overflow"))?;
let end =
start.checked_add(len).ok_or_else(|| invalid_input("asset range overflow"))?;
if end > bytes.len() {
return Err(invalid_input("asset range out of bounds"));
}
Ok(AssetsPayloadSlice::Memory { bytes: Arc::clone(bytes), start, len })
}
Self::File(source) => {
let end = offset
.checked_add(size)
.ok_or_else(|| invalid_input("asset range overflow"))?;
if end > source.payload_len {
return Err(invalid_input("asset range out of bounds"));
}
Ok(AssetsPayloadSlice::File { source: Arc::clone(source), offset, size })
}
}
}
}
#[derive(Debug)]
pub struct FileAssetsPayloadSource {
pub path: PathBuf,
pub payload_offset: u64,
pub payload_len: u64,
}
#[derive(Debug, Clone)]
pub enum AssetsPayloadSlice {
Memory { bytes: Arc<[u8]>, start: usize, len: usize },
File { source: Arc<FileAssetsPayloadSource>, offset: u64, size: u64 },
}
impl AssetsPayloadSlice {
pub fn open_reader(&self) -> io::Result<AssetsPayloadReader> {
match self {
Self::Memory { bytes, start, len } => {
let data = Arc::<[u8]>::from(bytes[*start..*start + *len].to_vec());
Ok(AssetsPayloadReader::Memory(Cursor::new(data)))
}
Self::File { source, offset, size } => {
let mut file = File::open(&source.path)?;
let absolute_start = source.payload_offset + offset;
file.seek(SeekFrom::Start(absolute_start))?;
Ok(AssetsPayloadReader::File(FileSliceReader {
file,
start: absolute_start,
len: *size,
position: 0,
}))
}
}
}
pub fn read_all(&self) -> io::Result<Vec<u8>> {
let mut reader = self.open_reader()?;
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
Ok(buffer)
}
}
pub enum AssetsPayloadReader {
Memory(Cursor<Arc<[u8]>>),
File(FileSliceReader),
}
impl Read for AssetsPayloadReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Self::Memory(reader) => reader.read(buf),
Self::File(reader) => reader.read(buf),
}
}
}
impl Seek for AssetsPayloadReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
match self {
Self::Memory(reader) => reader.seek(pos),
Self::File(reader) => reader.seek(pos),
}
}
}
pub struct FileSliceReader {
file: File,
start: u64,
len: u64,
position: u64,
}
impl Read for FileSliceReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.position >= self.len {
return Ok(0);
}
let remaining = (self.len - self.position) as usize;
let to_read = remaining.min(buf.len());
let read = self.file.read(&mut buf[..to_read])?;
self.position += read as u64;
Ok(read)
}
}
impl Seek for FileSliceReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let next = match pos {
SeekFrom::Start(offset) => offset as i128,
SeekFrom::Current(delta) => self.position as i128 + delta as i128,
SeekFrom::End(delta) => self.len as i128 + delta as i128,
};
if next < 0 || next as u64 > self.len {
return Err(invalid_input("slice seek out of bounds"));
}
let next = next as u64;
self.file.seek(SeekFrom::Start(self.start + next))?;
self.position = next;
Ok(self.position)
}
}
fn invalid_input(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AssetsPackPrelude {
pub magic: [u8; 4],
pub schema_version: u32,
pub header_len: u32,
pub payload_offset: u64,
pub flags: u32,
pub reserved: u32,
pub header_checksum: u32,
}
impl AssetsPackPrelude {
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() < ASSETS_PA_PRELUDE_SIZE {
return None;
}
let mut magic = [0_u8; 4];
magic.copy_from_slice(&bytes[0..4]);
Some(Self {
magic,
schema_version: u32::from_le_bytes(bytes[4..8].try_into().ok()?),
header_len: u32::from_le_bytes(bytes[8..12].try_into().ok()?),
payload_offset: u64::from_le_bytes(bytes[12..20].try_into().ok()?),
flags: u32::from_le_bytes(bytes[20..24].try_into().ok()?),
reserved: u32::from_le_bytes(bytes[24..28].try_into().ok()?),
header_checksum: u32::from_le_bytes(bytes[28..32].try_into().ok()?),
})
}
pub fn to_bytes(self) -> [u8; ASSETS_PA_PRELUDE_SIZE] {
let mut bytes = [0_u8; ASSETS_PA_PRELUDE_SIZE];
bytes[0..4].copy_from_slice(&self.magic);
bytes[4..8].copy_from_slice(&self.schema_version.to_le_bytes());
bytes[8..12].copy_from_slice(&self.header_len.to_le_bytes());
bytes[12..20].copy_from_slice(&self.payload_offset.to_le_bytes());
bytes[20..24].copy_from_slice(&self.flags.to_le_bytes());
bytes[24..28].copy_from_slice(&self.reserved.to_le_bytes());
bytes[28..32].copy_from_slice(&self.header_checksum.to_le_bytes());
bytes
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AssetsPackHeader {
#[serde(default)]
pub asset_table: Vec<AssetEntry>,
#[serde(default)]
pub preload: Vec<PreloadEntry>,
}
#[derive(Debug)]
pub enum CartridgeError {
NotFound,
InvalidFormat,
InvalidManifest,
UnsupportedVersion,
MissingProgram,
MissingAssets,
IoError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Capability {
None,
System,
Gfx,
Composer,
Gfx2d,
Gfxui,
Audio,
Fs,
Log,
Asset,
Bank,
All,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CartridgeManifest {
pub magic: String,
pub cartridge_version: u32,
pub app_id: u32,
pub title: String,
pub app_version: String,
pub app_mode: AppMode,
#[serde(default)]
pub capabilities: Vec<Capability>,
}

View File

@ -1,589 +0,0 @@
use crate::asset::{AssetEntry, BankType};
use crate::cartridge::{
ASSETS_PA_MAGIC, ASSETS_PA_PRELUDE_SIZE, ASSETS_PA_SCHEMA_VERSION, AssetsPackHeader,
AssetsPackPrelude, AssetsPayloadSource, Capability, Cartridge, CartridgeDTO, CartridgeError,
CartridgeManifest,
};
use crate::syscalls::{CapFlags, caps};
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub struct CartridgeLoader;
impl CartridgeLoader {
pub fn load(path: impl AsRef<Path>) -> Result<Cartridge, CartridgeError> {
let path = path.as_ref();
if !path.exists() {
return Err(CartridgeError::NotFound);
}
if path.is_dir() {
DirectoryCartridgeLoader::load(path)
} else if path.extension().is_some_and(|ext| ext == "pmc") {
PackedCartridgeLoader::load(path)
} else {
Err(CartridgeError::InvalidFormat)
}
}
}
pub struct DirectoryCartridgeLoader;
impl DirectoryCartridgeLoader {
pub fn load(path: &Path) -> Result<Cartridge, CartridgeError> {
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
return Err(CartridgeError::InvalidManifest);
}
let manifest_content =
fs::read_to_string(manifest_path).map_err(|_| CartridgeError::IoError)?;
let manifest: CartridgeManifest =
serde_json::from_str(&manifest_content).map_err(|_| CartridgeError::InvalidManifest)?;
// Additional validation as per requirements
if manifest.magic != "PMTU" {
return Err(CartridgeError::InvalidManifest);
}
if manifest.cartridge_version != 1 {
return Err(CartridgeError::UnsupportedVersion);
}
let capabilities = normalize_capabilities(&manifest.capabilities)?;
let program_path = path.join("program.pbx");
if !program_path.exists() {
return Err(CartridgeError::MissingProgram);
}
let program = fs::read(program_path).map_err(|_| CartridgeError::IoError)?;
let assets_pa_path = path.join("assets.pa");
let (assets, asset_table, preload) = if assets_pa_path.exists() {
let parsed = parse_assets_pack(&assets_pa_path)?;
(
AssetsPayloadSource::from_file(
assets_pa_path.clone(),
parsed.payload_offset,
parsed.payload_len,
),
parsed.header.asset_table,
parsed.header.preload,
)
} else {
if capabilities & caps::ASSET != 0 {
return Err(CartridgeError::MissingAssets);
}
(AssetsPayloadSource::empty(), Vec::new(), Vec::new())
};
let dto = CartridgeDTO {
app_id: manifest.app_id,
title: manifest.title,
app_version: manifest.app_version,
app_mode: manifest.app_mode,
capabilities,
program,
assets,
asset_table,
preload,
};
Ok(Cartridge::from(dto))
}
}
pub struct PackedCartridgeLoader;
impl PackedCartridgeLoader {
pub fn load(_path: &Path) -> Result<Cartridge, CartridgeError> {
Err(CartridgeError::InvalidFormat)
}
}
fn normalize_capabilities(capabilities: &[Capability]) -> Result<CapFlags, CartridgeError> {
let mut seen = HashSet::new();
let mut normalized = caps::NONE;
for capability in capabilities {
if !seen.insert(*capability) {
return Err(CartridgeError::InvalidManifest);
}
normalized |= match capability {
Capability::None => caps::NONE,
Capability::System => caps::SYSTEM,
Capability::Gfx => caps::GFX,
Capability::Composer => caps::COMPOSER,
Capability::Gfx2d => caps::GFX2D,
Capability::Gfxui => caps::GFXUI,
Capability::Audio => caps::AUDIO,
Capability::Fs => caps::FS,
Capability::Log => caps::LOG,
Capability::Asset => caps::ASSET,
Capability::Bank => caps::BANK,
Capability::All => caps::ALL,
};
}
Ok(normalized)
}
struct ParsedAssetsPack {
header: AssetsPackHeader,
payload_offset: u64,
payload_len: u64,
}
fn parse_assets_pack(path: &Path) -> Result<ParsedAssetsPack, CartridgeError> {
let mut file = fs::File::open(path).map_err(|_| CartridgeError::IoError)?;
let mut prelude_bytes = [0_u8; ASSETS_PA_PRELUDE_SIZE];
file.read_exact(&mut prelude_bytes).map_err(|_| CartridgeError::InvalidFormat)?;
let prelude =
AssetsPackPrelude::from_bytes(&prelude_bytes).ok_or(CartridgeError::InvalidFormat)?;
if prelude.magic != ASSETS_PA_MAGIC || prelude.schema_version != ASSETS_PA_SCHEMA_VERSION {
return Err(CartridgeError::InvalidFormat);
}
let header_start = ASSETS_PA_PRELUDE_SIZE;
let header_len =
usize::try_from(prelude.header_len).map_err(|_| CartridgeError::InvalidFormat)?;
let header_end = header_start.checked_add(header_len).ok_or(CartridgeError::InvalidFormat)?;
let payload_offset =
usize::try_from(prelude.payload_offset).map_err(|_| CartridgeError::InvalidFormat)?;
let file_len = usize::try_from(file.metadata().map_err(|_| CartridgeError::IoError)?.len())
.map_err(|_| CartridgeError::InvalidFormat)?;
if payload_offset < header_start || header_end > file_len || payload_offset > file_len {
return Err(CartridgeError::InvalidFormat);
}
if header_end != payload_offset {
return Err(CartridgeError::InvalidFormat);
}
file.seek(SeekFrom::Start(header_start as u64)).map_err(|_| CartridgeError::IoError)?;
let mut header_bytes = vec![0_u8; header_len];
file.read_exact(&mut header_bytes).map_err(|_| CartridgeError::InvalidFormat)?;
let header: AssetsPackHeader =
serde_json::from_slice(&header_bytes).map_err(|_| CartridgeError::InvalidFormat)?;
validate_preload(&header.asset_table, &header.preload)?;
let payload_len =
u64::try_from(file_len - payload_offset).map_err(|_| CartridgeError::InvalidFormat)?;
Ok(ParsedAssetsPack { header, payload_offset: prelude.payload_offset, payload_len })
}
fn validate_preload(
asset_table: &[AssetEntry],
preload: &[crate::asset::PreloadEntry],
) -> Result<(), CartridgeError> {
let mut claimed_slots = HashSet::<(BankType, usize)>::new();
for item in preload {
let entry = asset_table
.iter()
.find(|entry| entry.asset_id == item.asset_id)
.ok_or(CartridgeError::InvalidFormat)?;
if !claimed_slots.insert((entry.bank_type, item.slot)) {
return Err(CartridgeError::InvalidFormat);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::{AssetCodec, AssetEntry, BankType, PreloadEntry};
use crate::cartridge::{ASSETS_PA_MAGIC, ASSETS_PA_SCHEMA_VERSION, AssetsPackPrelude};
use crate::glyph_bank::GLYPH_BANK_PALETTE_COUNT_V1;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestCartridgeDir {
path: PathBuf,
}
impl TestCartridgeDir {
fn new(manifest: serde_json::Value) -> Self {
let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time must be after unix epoch")
.as_nanos();
let path = std::env::temp_dir()
.join(format!("prometeu-hal-cartridge-loader-{}-{}", timestamp, unique));
fs::create_dir_all(&path).expect("must create temporary cartridge directory");
fs::write(
path.join("manifest.json"),
serde_json::to_vec_pretty(&manifest).expect("manifest must serialize"),
)
.expect("must write manifest.json");
fs::write(path.join("program.pbx"), [0x01_u8, 0x02, 0x03]).expect("must write program");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn write_assets_pa(
&self,
asset_table: Vec<AssetEntry>,
preload: Vec<PreloadEntry>,
payload: &[u8],
) {
let header = serde_json::to_vec(&AssetsPackHeader { asset_table, preload })
.expect("assets header must serialize");
let payload_offset = (ASSETS_PA_PRELUDE_SIZE + header.len()) as u64;
let prelude = AssetsPackPrelude {
magic: ASSETS_PA_MAGIC,
schema_version: ASSETS_PA_SCHEMA_VERSION,
header_len: header.len() as u32,
payload_offset,
flags: 0,
reserved: 0,
header_checksum: 0,
};
let mut bytes = prelude.to_bytes().to_vec();
bytes.extend_from_slice(&header);
bytes.extend_from_slice(payload);
fs::write(self.path.join("assets.pa"), bytes).expect("must write assets.pa");
}
}
impl Drop for TestCartridgeDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn manifest_with_capabilities(capabilities: Option<Vec<&str>>) -> serde_json::Value {
let mut manifest = json!({
"magic": "PMTU",
"cartridge_version": 1,
"app_id": 1001,
"title": "Example",
"app_version": "1.0.0",
"app_mode": "Game"
});
if let Some(capabilities) = capabilities {
manifest["capabilities"] = json!(capabilities);
}
manifest
}
#[test]
fn load_without_capabilities_defaults_to_none() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(None));
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert_eq!(cartridge.capabilities, caps::NONE);
}
#[test]
fn load_with_single_capability_normalizes_to_flag() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx"])));
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert_eq!(cartridge.capabilities, caps::GFX);
}
#[test]
fn load_with_multiple_capabilities_combines_flags() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx", "audio"])));
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert_eq!(cartridge.capabilities, caps::GFX | caps::AUDIO);
}
#[test]
fn load_with_all_capability_normalizes_to_all_flags() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["all"])));
dir.write_assets_pa(vec![], vec![], &[]);
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert_eq!(cartridge.capabilities, caps::ALL);
}
#[test]
fn load_with_none_capability_keeps_zero_flags() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["none"])));
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert_eq!(cartridge.capabilities, caps::NONE);
}
#[test]
fn load_with_duplicate_capabilities_fails() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx", "gfx"])));
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidManifest));
}
#[test]
fn load_with_unknown_capability_fails() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["network"])));
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidManifest));
}
#[test]
fn load_with_legacy_input_capability_fails() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["input"])));
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidManifest));
}
fn test_asset_entry(offset: u64, size: u64) -> AssetEntry {
AssetEntry {
asset_id: 7,
asset_name: "tiles".to_string(),
bank_type: BankType::GLYPH,
offset,
size,
decoded_size: 16 * 16 + (GLYPH_BANK_PALETTE_COUNT_V1 as u64 * 16 * 4),
codec: AssetCodec::None,
metadata: json!({
"tile_size": 16,
"width": 16,
"height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1
}),
}
}
#[test]
fn load_with_asset_capability_requires_assets_pa() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::MissingAssets));
}
#[test]
fn load_without_asset_capability_accepts_missing_assets_pa() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["gfx"])));
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
assert!(cartridge.assets.is_empty());
assert!(cartridge.asset_table.is_empty());
assert!(cartridge.preload.is_empty());
}
#[test]
fn load_reads_asset_table_and_preload_from_assets_pa_header() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let payload = vec![1_u8, 2, 3, 4];
dir.write_assets_pa(
vec![test_asset_entry(0, payload.len() as u64)],
vec![PreloadEntry { asset_id: 7, slot: 2 }],
&payload,
);
let cartridge = DirectoryCartridgeLoader::load(dir.path()).expect("cartridge must load");
let slice = cartridge
.assets
.open_slice(0, payload.len() as u64)
.expect("payload slice must open")
.read_all()
.expect("payload slice must read");
assert_eq!(slice, payload);
assert_eq!(cartridge.asset_table.len(), 1);
assert_eq!(cartridge.asset_table[0].asset_name, "tiles");
assert_eq!(cartridge.preload.len(), 1);
assert_eq!(cartridge.preload[0].asset_id, 7);
assert_eq!(cartridge.preload[0].slot, 2);
}
#[test]
fn load_rejects_preload_with_missing_asset_id() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
dir.write_assets_pa(
vec![test_asset_entry(0, 4)],
vec![PreloadEntry { asset_id: 999, slot: 2 }],
&[1_u8, 2, 3, 4],
);
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
#[test]
fn load_rejects_preload_slot_clash_per_bank_type() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let asset_table = vec![
test_asset_entry(0, 4),
AssetEntry {
asset_id: 8,
asset_name: "other_tiles".to_string(),
bank_type: BankType::GLYPH,
offset: 4,
size: 4,
decoded_size: 16 * 16 + (GLYPH_BANK_PALETTE_COUNT_V1 as u64 * 16 * 4),
codec: AssetCodec::None,
metadata: json!({
"tile_size": 16,
"width": 16,
"height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1
}),
},
];
let preload =
vec![PreloadEntry { asset_id: 7, slot: 2 }, PreloadEntry { asset_id: 8, slot: 2 }];
dir.write_assets_pa(asset_table, preload, &[1_u8, 2, 3, 4, 5, 6, 7, 8]);
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
#[test]
fn load_rejects_invalid_assets_pa_prelude() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
fs::write(dir.path().join("assets.pa"), b"not-a-valid-pack").expect("must write assets.pa");
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
#[test]
fn load_rejects_invalid_assets_pa_header_json() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let prelude = AssetsPackPrelude {
magic: ASSETS_PA_MAGIC,
schema_version: ASSETS_PA_SCHEMA_VERSION,
header_len: 4,
payload_offset: (ASSETS_PA_PRELUDE_SIZE + 4) as u64,
flags: 0,
reserved: 0,
header_checksum: 0,
};
let mut bytes = prelude.to_bytes().to_vec();
bytes.extend_from_slice(b"{no}");
fs::write(dir.path().join("assets.pa"), bytes).expect("must write assets.pa");
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
#[test]
fn load_rejects_unknown_codec_string_in_assets_pa_header() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let header = serde_json::json!({
"asset_table": [{
"asset_id": 7,
"asset_name": "tiles",
"bank_type": "GLYPH",
"offset": 0,
"size": 4,
"decoded_size": 768,
"codec": "LZ4",
"metadata": {
"tile_size": 16,
"width": 16,
"height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1
}
}],
"preload": []
});
let header_bytes = serde_json::to_vec(&header).expect("header must serialize");
let prelude = AssetsPackPrelude {
magic: ASSETS_PA_MAGIC,
schema_version: ASSETS_PA_SCHEMA_VERSION,
header_len: header_bytes.len() as u32,
payload_offset: (ASSETS_PA_PRELUDE_SIZE + header_bytes.len()) as u64,
flags: 0,
reserved: 0,
header_checksum: 0,
};
let mut bytes = prelude.to_bytes().to_vec();
bytes.extend_from_slice(&header_bytes);
bytes.extend_from_slice(&[1_u8, 2, 3, 4]);
fs::write(dir.path().join("assets.pa"), bytes).expect("must write assets.pa");
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
#[test]
fn load_rejects_legacy_raw_codec_string_in_assets_pa_header() {
let dir = TestCartridgeDir::new(manifest_with_capabilities(Some(vec!["asset"])));
let header = serde_json::json!({
"asset_table": [{
"asset_id": 7,
"asset_name": "tiles",
"bank_type": "GLYPH",
"offset": 0,
"size": 4,
"decoded_size": 768,
"codec": "RAW",
"metadata": {
"tile_size": 16,
"width": 16,
"height": 16,
"palette_count": GLYPH_BANK_PALETTE_COUNT_V1,
"palette_authored": GLYPH_BANK_PALETTE_COUNT_V1
}
}],
"preload": []
});
let header_bytes = serde_json::to_vec(&header).expect("header must serialize");
let prelude = AssetsPackPrelude {
magic: ASSETS_PA_MAGIC,
schema_version: ASSETS_PA_SCHEMA_VERSION,
header_len: header_bytes.len() as u32,
payload_offset: (ASSETS_PA_PRELUDE_SIZE + header_bytes.len()) as u64,
flags: 0,
reserved: 0,
header_checksum: 0,
};
let mut bytes = prelude.to_bytes().to_vec();
bytes.extend_from_slice(&header_bytes);
bytes.extend_from_slice(&[1_u8, 2, 3, 4]);
fs::write(dir.path().join("assets.pa"), bytes).expect("must write assets.pa");
let error = DirectoryCartridgeLoader::load(dir.path()).unwrap_err();
assert!(matches!(error, CartridgeError::InvalidFormat));
}
}

View File

@ -1,67 +0,0 @@
/// Represents a canonical RGBA8888 color in RGBA channel order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Color(pub u32);
impl Color {
pub const BLACK: Self = Self::rgb(0, 0, 0);
pub const WHITE: Self = Self::rgb(255, 255, 255);
pub const RED: Self = Self::rgb(255, 0, 0);
pub const GREEN: Self = Self::rgb(0, 255, 0);
pub const BLUE: Self = Self::rgb(0, 0, 255);
pub const YELLOW: Self = Self::rgb(255, 255, 0);
pub const ORANGE: Self = Self::rgb(255, 128, 0);
pub const INDIGO: Self = Self::rgb(75, 0, 130);
pub const GRAY: Self = Self::rgb(128, 128, 128);
pub const CYAN: Self = Self::rgb(0, 255, 255);
pub const MAGENTA: Self = Self::rgb(255, 0, 255);
pub const TRANSPARENT: Self = Self::rgba(0, 0, 0, 0);
#[deprecated(note = "Use RGBA alpha transparency; COLOR_KEY is temporary migration residue.")]
pub const COLOR_KEY: Self = Self::TRANSPARENT;
/// Extracts RGBA8888 channels from canonical RGBA raw order.
#[inline(always)]
pub const fn unpack_to_native(px: u32) -> (u8, u8, u8, u8) {
let r = ((px >> 24) & 0xFF) as u8;
let g = ((px >> 16) & 0xFF) as u8;
let b = ((px >> 8) & 0xFF) as u8;
let a = (px & 0xFF) as u8;
(r, g, b, a)
}
/// Packs RGBA8888 channels into canonical RGBA raw order.
#[inline(always)]
pub const fn pack_from_native(r: u8, g: u8, b: u8, a: u8) -> u32 {
((r as u32) << 24) | ((g as u32) << 16) | ((b as u32) << 8) | a as u32
}
/// Creates an opaque RGBA8888 color from 8-bit RGB components.
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::rgba(r, g, b, 255)
}
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self(Self::pack_from_native(r, g, b, a))
}
pub const fn gray_scale(c: u8) -> Self {
Self::rgb(c, c, c)
}
pub const fn from_raw(raw: u32) -> Self {
Self(raw)
}
pub const fn raw(self) -> u32 {
self.0
}
pub const fn hex(self) -> i32 {
let (r, g, b, _) = Self::unpack_to_native(self.0);
let hex = (r as u32) << 16 | (g as u32) << 8 | b as u32;
hex as i32
}
pub const fn alpha(self) -> u8 {
(self.0 & 0xFF) as u8
}
}

View File

@ -1,10 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum ComposerOpStatus {
Ok = 0,
SceneUnavailable = 1,
ArgRangeInvalid = 2,
BankInvalid = 3,
LayerInvalid = 4,
SpriteOverflow = 5,
}

View File

@ -1,147 +0,0 @@
use crate::app_mode::AppMode;
use prometeu_bytecode::Value;
use serde::{Deserialize, Serialize};
pub const DEVTOOLS_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DebugCommand {
#[serde(rename = "ok")]
Ok,
#[serde(rename = "start")]
Start,
#[serde(rename = "pause")]
Pause,
#[serde(rename = "resume")]
Resume,
#[serde(rename = "step")]
Step,
#[serde(rename = "stepFrame")]
StepFrame,
#[serde(rename = "getState")]
GetState,
#[serde(rename = "setBreakpoint")]
SetBreakpoint { pc: usize },
#[serde(rename = "listBreakpoints")]
ListBreakpoints,
#[serde(rename = "clearBreakpoint")]
ClearBreakpoint { pc: usize },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DebugResponse {
#[serde(rename = "handshake")]
Handshake { protocol_version: u32, runtime_version: String, cartridge: HandshakeCartridge },
#[serde(rename = "getState")]
GetState { pc: usize, stack_top: Vec<Value>, frame_index: u64, app_id: u32 },
#[serde(rename = "breakpoints")]
Breakpoints { pcs: Vec<usize> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandshakeCartridge {
pub app_id: u32,
pub title: String,
pub app_version: String,
pub app_mode: AppMode,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "event")]
pub enum DebugEvent {
#[serde(rename = "breakpointHit")]
BreakpointHit { pc: usize, frame_index: u64 },
#[serde(rename = "log")]
Log { level: String, source: String, msg: String },
#[serde(rename = "telemetry")]
Telemetry {
frame_index: u64,
vm_steps: u32,
syscalls: u32,
cycles: u64,
cycles_budget: u64,
host_cpu_time_us: u64,
violations: u32,
glyph_slots_used: u32,
glyph_slots_total: u32,
sound_slots_used: u32,
sound_slots_total: u32,
scene_slots_used: u32,
scene_slots_total: u32,
},
#[serde(rename = "fault")]
Fault {
kind: String,
summary: String,
pc: Option<u32>,
trap_code: Option<u32>,
opcode: Option<u16>,
},
#[serde(rename = "cert")]
Cert { rule: String, used: u64, limit: u64, frame_index: u64 },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_telemetry_event_serialization() {
let event = DebugEvent::Telemetry {
frame_index: 10,
vm_steps: 100,
syscalls: 5,
cycles: 5000,
cycles_budget: 10000,
host_cpu_time_us: 1200,
violations: 0,
glyph_slots_used: 1,
glyph_slots_total: 16,
sound_slots_used: 2,
sound_slots_total: 16,
scene_slots_used: 3,
scene_slots_total: 16,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"telemetry\""));
assert!(json.contains("\"cycles\":5000"));
assert!(json.contains("\"cycles_budget\":10000"));
}
#[test]
fn test_get_state_serialization() {
let resp = DebugResponse::GetState {
pc: 42,
stack_top: vec![Value::Int64(10), Value::String("test".into()), Value::Boolean(true)],
frame_index: 5,
app_id: 1,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"type\":\"getState\""));
assert!(json.contains("\"pc\":42"));
assert!(json.contains("\"stack_top\":[10,\"test\",true]"));
assert!(json.contains("\"frame_index\":5"));
assert!(json.contains("\"app_id\":1"));
}
#[test]
fn test_fault_event_serialization() {
let event = DebugEvent::Fault {
kind: "vm_trap".into(),
summary: "PVM Trap 0x00000004".into(),
pc: Some(12),
trap_code: Some(4),
opcode: Some(0x70),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"fault\""));
assert!(json.contains("\"kind\":\"vm_trap\""));
assert!(json.contains("\"trap_code\":4"));
assert!(json.contains("\"opcode\":112"));
}
}

View File

@ -1,76 +0,0 @@
use crate::color::Color;
use crate::scene_viewport_cache::SceneViewportCache;
use crate::scene_viewport_resolver::ResolverUpdate;
use crate::sprite::Sprite;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlendMode {
None,
Half,
HalfPlus,
HalfMinus,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum GfxOpStatus {
Ok = 0,
AssetNotFound = 1,
InvalidSpriteIndex = 2,
ArgRangeInvalid = 3,
BankInvalid = 4,
}
pub trait GfxBridge {
fn size(&self) -> (usize, usize);
fn front_buffer(&self) -> &[u32];
fn clear(&mut self, color: Color);
fn fill_rect_blend(&mut self, x: i32, y: i32, w: i32, h: i32, color: Color, mode: BlendMode);
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: Color);
fn draw_pixel(&mut self, x: i32, y: i32, color: Color);
fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color);
fn draw_circle(&mut self, xc: i32, yc: i32, r: i32, color: Color);
fn draw_circle_points(&mut self, xc: i32, yc: i32, x: i32, y: i32, color: Color);
fn fill_circle(&mut self, xc: i32, yc: i32, r: i32, color: Color);
fn draw_circle_lines(&mut self, xc: i32, yc: i32, x: i32, y: i32, color: Color);
fn draw_disc(&mut self, x: i32, y: i32, r: i32, border_color: Color, fill_color: Color);
fn draw_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: Color);
fn draw_square(
&mut self,
x: i32,
y: i32,
w: i32,
h: i32,
border_color: Color,
fill_color: Color,
);
fn draw_horizontal_line(&mut self, x0: i32, x1: i32, y: i32, color: Color);
fn draw_vertical_line(&mut self, x: i32, y0: i32, y1: i32, color: Color);
fn present(&mut self);
/// Render the canonical game frame with no bound scene.
///
/// Deferred `gfx.*` overlay/debug primitives are intentionally outside this
/// contract and are drained by a separate final overlay stage.
fn render_no_scene_frame(&mut self);
/// Render the canonical scene-backed game frame from cache/resolver state.
///
/// Deferred `gfx.*` overlay/debug primitives are intentionally outside this
/// contract and are drained by a separate final overlay stage.
fn render_scene_from_cache(
&mut self,
cache: &SceneViewportCache,
update: &ResolverUpdate,
resolved_glyph_slots: &[usize; 4],
);
fn load_frame_sprites(&mut self, sprites: &[Sprite]);
/// Submit text into the `gfx.*` primitive path.
///
/// Under the accepted runtime contract this is not the canonical game
/// composition path; it belongs to the deferred final overlay/debug family.
fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color);
fn draw_char(&mut self, x: i32, y: i32, c: char, color: Color);
fn sprite(&self, index: usize) -> &Sprite;
fn sprite_mut(&mut self, index: usize) -> &mut Sprite;
}

View File

@ -1,7 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct Glyph {
pub glyph_id: u16,
pub palette_id: u8,
}

View File

@ -1,81 +0,0 @@
use crate::color::Color;
use serde::{Deserialize, Serialize};
pub const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64;
pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16;
/// Standard sizes for square tiles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum TileSize {
/// 8x8 pixels.
Size8 = 8,
/// 16x16 pixels.
Size16 = 16,
/// 32x32 pixels.
Size32 = 32,
}
/// A container for graphical assets.
///
/// A GlyphBank stores the decoded runtime representation of a glyph-bank asset.
///
/// Serialized `assets.pa` payloads keep pixel indices packed as `4bpp` nibbles.
/// After decode, the runtime expands them to one `u8` palette index per pixel
/// while preserving the same `0..15` logical range.
pub struct GlyphBank {
/// Dimension of each individual tile in the bank.
pub tile_size: TileSize,
/// Width of the full bank sheet in pixels.
pub width: usize,
/// Height of the full bank sheet in pixels.
pub height: usize,
/// Decoded pixel data stored as one palette index per pixel.
/// Serialized payloads are packed; runtime memory is expanded.
/// Palette indices are ordinary indices; transparency is resolved through
/// the RGBA alpha channel of the palette entry.
pub pixel_indices: Vec<u8>,
/// Runtime-facing v1 palette table: 64 palettes of 16 RGBA8888 colors each.
pub palettes: [[Color; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1],
}
impl GlyphBank {
/// Creates an empty glyph bank with the specified dimensions.
pub fn new(tile_size: TileSize, width: usize, height: usize) -> Self {
Self {
tile_size,
width,
height,
pixel_indices: vec![0; width * height],
palettes: [[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1],
}
}
/// Resolves a global tile ID and local pixel coordinates to a palette index.
/// tile_id: the tile index in the bank
/// local_x, local_y: the pixel position inside the tile (0 to tile_size-1)
pub fn get_pixel_index(&self, tile_id: u16, local_x: usize, local_y: usize) -> u8 {
let size = self.tile_size as usize;
let tiles_per_row = self.width / size;
let tile_x = (tile_id as usize % tiles_per_row) * size;
let tile_y = (tile_id as usize / tiles_per_row) * size;
let pixel_x = tile_x + local_x;
let pixel_y = tile_y + local_y;
if pixel_x < self.width && pixel_y < self.height {
self.pixel_indices[pixel_y * self.width + pixel_x]
} else {
0
}
}
/// Maps a 4-bit index to a real RGBA8888 Color using the specified palette.
pub fn resolve_color(&self, palette_id: u8, pixel_index: u8) -> Color {
self.palettes
.get(palette_id as usize)
.and_then(|palette| palette.get(pixel_index as usize))
.copied()
.unwrap_or(Color::TRANSPARENT)
}
}

View File

@ -1,28 +0,0 @@
use crate::platform::RuntimePlatform;
use crate::vm_fault::VmFault;
pub struct HostContext<'a> {
pub platform: Option<&'a mut dyn RuntimePlatform>,
}
impl<'a> HostContext<'a> {
pub fn new(platform: Option<&'a mut dyn RuntimePlatform>) -> Self {
Self { platform }
}
pub fn new_platform(platform: Option<&'a mut dyn RuntimePlatform>) -> Self {
Self::new(platform)
}
#[inline]
pub fn require_platform(&mut self) -> Result<&mut dyn RuntimePlatform, VmFault> {
match &mut self.platform {
Some(platform) => Ok(*platform),
None => Err(VmFault::Unavailable),
}
}
}
pub trait HostContextProvider {
fn make_ctx(&'_ mut self) -> HostContext<'_>;
}

View File

@ -1,27 +0,0 @@
use prometeu_bytecode::{HeapRef, Value};
pub struct HostReturn<'a> {
stack: &'a mut Vec<Value>,
}
impl<'a> HostReturn<'a> {
pub fn new(stack: &'a mut Vec<Value>) -> Self {
Self { stack }
}
pub fn push_bool(&mut self, v: bool) {
self.stack.push(Value::Boolean(v));
}
pub fn push_int(&mut self, v: i64) {
self.stack.push(Value::Int64(v));
}
pub fn push_null(&mut self) {
self.stack.push(Value::Null);
}
pub fn push_gate(&mut self, g: usize) {
// Temporary: cast incoming handle/HeapRef index. Real allocator will provide proper handles.
self.stack.push(Value::HeapRef(HeapRef(g as u32)));
}
pub fn push_string(&mut self, s: String) {
self.stack.push(Value::string(s));
}
}

View File

@ -1,66 +0,0 @@
pub mod app_mode;
pub mod asset;
pub mod asset_bridge;
pub mod audio_bridge;
pub mod button;
pub mod cartridge;
pub mod cartridge_loader;
pub mod color;
pub mod composer_status;
pub mod debugger_protocol;
pub mod gfx_bridge;
pub mod glyph;
pub mod glyph_bank;
pub mod host_context;
pub mod host_return;
pub mod input_signals;
pub mod log;
pub mod native_helpers;
pub mod native_interface;
pub mod owned_frame;
pub mod pad_bridge;
pub mod platform;
pub mod primitives;
pub mod render_resources;
pub mod render_submission;
pub mod render_worker;
pub mod sample;
pub mod scene_bank;
pub mod scene_layer;
pub mod scene_viewport_cache;
pub mod scene_viewport_resolver;
pub mod sound_bank;
pub mod sprite;
pub mod syscalls;
pub mod telemetry;
pub mod tile;
pub mod tilemap;
pub mod touch_bridge;
pub mod vm_fault;
pub use asset_bridge::AssetBridge;
pub use audio_bridge::{AudioBridge, AudioOpStatus, LoopMode};
pub use composer_status::ComposerOpStatus;
pub use gfx_bridge::{BlendMode, GfxBridge, GfxOpStatus};
pub use host_context::{HostContext, HostContextProvider};
pub use host_return::HostReturn;
pub use input_signals::InputSignals;
pub use native_helpers::{expect_bool, expect_int};
pub use native_interface::{NativeInterface, SyscallId};
pub use owned_frame::{OwnedRgba8888Frame, OwnedRgba8888FrameError};
pub use pad_bridge::PadBridge;
pub use platform::{
AssetStoragePlatform, AudioPlatform, ClockPacingPlatform, Game2DFrameComposer, InputPlatform,
NoopTelemetryPlatform, RenderBackend, RenderSubmissionSink, RenderSubmitError, RuntimePlatform,
TelemetryPlatform, TestPlatform,
};
pub use render_resources::RenderResourceAccess;
pub use render_submission::{
BoundScenePacket, Camera2D, ComposerFramePacket, FrameId, Game2DFramePacket, GameSpritePacket,
Gfx2dCommand, GfxUiCommand, HudCommand, HudPacket, RenderOwnership, RenderSubmission,
RenderSubmissionPacket, ShellUiFramePacket,
};
pub use render_worker::{
RenderWorkerBackend, RenderWorkerError, RenderWorkerFrameSink, RenderWorkerTelemetry,
};
pub use touch_bridge::TouchBridge;

View File

@ -1,17 +0,0 @@
use crate::vm_fault::VmFault;
use prometeu_bytecode::{TRAP_TYPE, Value};
pub fn expect_int(args: &[Value], idx: usize) -> Result<i64, VmFault> {
args.get(idx)
.and_then(|v| v.as_integer())
.ok_or_else(|| VmFault::Trap(TRAP_TYPE, format!("Expected integer at index {}", idx)))
}
pub fn expect_bool(args: &[Value], idx: usize) -> Result<bool, VmFault> {
args.get(idx)
.and_then(|v| match v {
Value::Boolean(b) => Some(*b),
_ => None,
})
.ok_or_else(|| VmFault::Trap(TRAP_TYPE, format!("Expected boolean at index {}", idx)))
}

View File

@ -1,21 +0,0 @@
use crate::host_context::HostContext;
use crate::host_return::HostReturn;
use crate::vm_fault::VmFault;
use prometeu_bytecode::Value;
pub type SyscallId = u32;
pub trait NativeInterface {
/// Dispatches a syscall from the Virtual Machine to the native implementation.
///
/// ABI Rule: Arguments for the syscall are passed in `args`.
///
/// Returns are written via `ret`.
fn syscall(
&mut self,
id: SyscallId,
args: &[Value],
ret: &mut HostReturn,
ctx: &mut HostContext,
) -> Result<(), VmFault>;
}

View File

@ -1,127 +0,0 @@
use crate::render_submission::{FrameId, RenderOwnership};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedRgba8888Frame {
pub frame_id: FrameId,
pub ownership: RenderOwnership,
pub width: usize,
pub height: usize,
pub stride_pixels: usize,
pub pixels: Vec<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnedRgba8888FrameError {
EmptyDimensions,
StrideTooSmall,
PixelBufferTooSmall,
}
impl OwnedRgba8888Frame {
pub fn new(
frame_id: FrameId,
ownership: RenderOwnership,
width: usize,
height: usize,
stride_pixels: usize,
pixels: Vec<u32>,
) -> Result<Self, OwnedRgba8888FrameError> {
if width == 0 || height == 0 {
return Err(OwnedRgba8888FrameError::EmptyDimensions);
}
if stride_pixels < width {
return Err(OwnedRgba8888FrameError::StrideTooSmall);
}
let required_len = stride_pixels
.checked_mul(height)
.ok_or(OwnedRgba8888FrameError::PixelBufferTooSmall)?;
if pixels.len() < required_len {
return Err(OwnedRgba8888FrameError::PixelBufferTooSmall);
}
Ok(Self { frame_id, ownership, width, height, stride_pixels, pixels })
}
pub fn packed(
frame_id: FrameId,
ownership: RenderOwnership,
width: usize,
height: usize,
pixels: Vec<u32>,
) -> Result<Self, OwnedRgba8888FrameError> {
Self::new(frame_id, ownership, width, height, width, pixels)
}
pub fn required_pixel_len(&self) -> usize {
self.stride_pixels * self.height
}
pub fn row(&self, y: usize) -> Option<&[u32]> {
if y >= self.height {
return None;
}
let start = y * self.stride_pixels;
Some(&self.pixels[start..start + self.width])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_mode::AppMode;
fn ownership() -> RenderOwnership {
RenderOwnership::new(11, AppMode::Game, 42)
}
#[test]
fn owned_rgba8888_frame_preserves_metadata_and_pixels() {
let pixels = vec![0x11223344, 0x55667788, 0x99AABBCC, 0xDDEEFF00];
let frame = OwnedRgba8888Frame::packed(FrameId::new(7), ownership(), 2, 2, pixels.clone())
.expect("valid packed RGBA8888 frame");
assert_eq!(frame.frame_id, FrameId::new(7));
assert_eq!(frame.ownership, ownership());
assert_eq!(frame.width, 2);
assert_eq!(frame.height, 2);
assert_eq!(frame.stride_pixels, 2);
assert_eq!(frame.pixels, pixels);
assert_eq!(frame.required_pixel_len(), 4);
}
#[test]
fn checked_constructor_rejects_empty_dimensions() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 0, 1, 1, vec![0])
.expect_err("zero width should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::EmptyDimensions);
}
#[test]
fn checked_constructor_rejects_stride_smaller_than_width() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 3, 1, 2, vec![0, 0, 0])
.expect_err("stride smaller than width should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::StrideTooSmall);
}
#[test]
fn checked_constructor_rejects_short_pixel_buffer() {
let err = OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 2, 2, 3, vec![0; 5])
.expect_err("buffer shorter than stride * height should be rejected");
assert_eq!(err, OwnedRgba8888FrameError::PixelBufferTooSmall);
}
#[test]
fn row_returns_visible_width_without_stride_padding() {
let frame =
OwnedRgba8888Frame::new(FrameId::ZERO, ownership(), 2, 2, 3, vec![1, 2, 99, 3, 4, 88])
.expect("valid padded frame");
assert_eq!(frame.row(0), Some(&[1, 2][..]));
assert_eq!(frame.row(1), Some(&[3, 4][..]));
assert_eq!(frame.row(2), None);
}
}

View File

@ -1,20 +0,0 @@
use crate::button::Button;
use crate::input_signals::InputSignals;
pub trait PadBridge {
fn begin_frame(&mut self, signals: &InputSignals);
fn any(&self) -> bool;
fn up(&self) -> &Button;
fn down(&self) -> &Button;
fn left(&self) -> &Button;
fn right(&self) -> &Button;
fn a(&self) -> &Button;
fn b(&self) -> &Button;
fn x(&self) -> &Button;
fn y(&self) -> &Button;
fn l(&self) -> &Button;
fn r(&self) -> &Button;
fn start(&self) -> &Button;
fn select(&self) -> &Button;
}

View File

@ -1,140 +0,0 @@
use crate::asset_bridge::AssetBridge;
use crate::audio_bridge::AudioBridge;
use crate::composer_status::ComposerOpStatus;
use crate::pad_bridge::PadBridge;
use crate::render_submission::{Game2DFramePacket, RenderSubmission};
use crate::sprite::Sprite;
use crate::telemetry::TelemetryFrame;
use crate::touch_bridge::TouchBridge;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderSubmitError {
BackendUnavailable,
SubmissionRejected,
}
pub trait RenderSubmissionSink {
fn submit_render_submission(
&mut self,
submission: RenderSubmission,
) -> Result<(), RenderSubmitError>;
}
pub trait RenderBackend {
fn render_frame(&mut self);
}
pub trait Game2DFrameComposer {
fn begin_frame(&mut self);
fn bind_scene(&mut self, scene_bank_id: usize) -> ComposerOpStatus;
fn unbind_scene(&mut self);
fn set_camera(&mut self, x: i32, y: i32) -> ComposerOpStatus;
fn emit_sprite(&mut self, sprite: Sprite) -> ComposerOpStatus;
fn close_game2d_packet(&self) -> Game2DFramePacket;
}
pub trait AudioPlatform: AudioBridge {}
impl<T: AudioBridge + ?Sized> AudioPlatform for T {}
pub trait InputPlatform {
fn pad(&self) -> &dyn PadBridge;
fn pad_mut(&mut self) -> &mut dyn PadBridge;
fn touch(&self) -> &dyn TouchBridge;
fn touch_mut(&mut self) -> &mut dyn TouchBridge;
}
pub trait AssetStoragePlatform: AssetBridge {}
impl<T: AssetBridge + ?Sized> AssetStoragePlatform for T {}
pub trait ClockPacingPlatform {}
pub trait TelemetryPlatform {
fn telemetry_snapshot(&self) -> Option<TelemetryFrame> {
None
}
}
#[derive(Debug, Default)]
pub struct NoopTelemetryPlatform;
impl TelemetryPlatform for NoopTelemetryPlatform {}
pub trait RuntimePlatform {
fn display_size(&self) -> (usize, usize) {
(480, 270)
}
fn render_submission_sink(&mut self) -> &mut dyn RenderSubmissionSink;
fn render_backend(&mut self) -> &mut dyn RenderBackend;
fn game2d_frame_composer(&mut self) -> &mut dyn Game2DFrameComposer;
fn audio(&self) -> &dyn AudioBridge;
fn audio_mut(&mut self) -> &mut dyn AudioBridge;
fn input(&self) -> &dyn InputPlatform;
fn input_mut(&mut self) -> &mut dyn InputPlatform;
fn assets(&self) -> &dyn AssetBridge;
fn assets_mut(&mut self) -> &mut dyn AssetBridge;
fn telemetry(&self) -> &dyn TelemetryPlatform;
}
pub trait TestPlatform: RuntimePlatform {}
impl<T: RuntimePlatform + ?Sized> TestPlatform for T {}
#[cfg(test)]
mod tests {
use super::*;
fn assert_render_sink_object(_: &mut dyn RenderSubmissionSink) {}
fn assert_backend_object(_: &mut dyn RenderBackend) {}
fn assert_composer_object(_: &mut dyn Game2DFrameComposer) {}
struct CompileOnly;
impl RenderSubmissionSink for CompileOnly {
fn submit_render_submission(
&mut self,
_submission: RenderSubmission,
) -> Result<(), RenderSubmitError> {
Ok(())
}
}
impl RenderBackend for CompileOnly {
fn render_frame(&mut self) {}
}
impl Game2DFrameComposer for CompileOnly {
fn begin_frame(&mut self) {}
fn bind_scene(&mut self, _scene_bank_id: usize) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn unbind_scene(&mut self) {}
fn set_camera(&mut self, _x: i32, _y: i32) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn emit_sprite(&mut self, _sprite: Sprite) -> ComposerOpStatus {
ComposerOpStatus::Ok
}
fn close_game2d_packet(&self) -> Game2DFramePacket {
Game2DFramePacket::default()
}
}
#[test]
fn core_platform_facades_are_object_safe() {
let mut compile_only = CompileOnly;
assert_render_sink_object(&mut compile_only);
assert_backend_object(&mut compile_only);
assert_composer_object(&mut compile_only);
}
#[test]
fn render_submit_error_is_typed_and_debuggable() {
assert_eq!(format!("{:?}", RenderSubmitError::BackendUnavailable), "BackendUnavailable");
}
}

View File

@ -1,7 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}

View File

@ -1,109 +0,0 @@
use crate::glyph_bank::GlyphBank;
use crate::scene_bank::SceneBank;
/// Read-only render resource lookup used by render backends.
///
/// Resource ids are stable bank slots carried by render submissions. The
/// callback shape keeps bank ownership inside the provider while exposing only
/// borrowed read access to the caller.
pub trait RenderResourceAccess {
/// Reads a resident glyph bank by id.
fn with_glyph_bank<R>(&self, bank_id: usize, read: impl FnOnce(&GlyphBank) -> R) -> Option<R>;
/// Returns the number of glyph bank ids accepted by this provider.
fn glyph_bank_count(&self) -> usize;
/// Reads a resident scene bank by id.
fn with_scene_bank<R>(&self, bank_id: usize, read: impl FnOnce(&SceneBank) -> R) -> Option<R>;
/// Returns the number of scene bank ids accepted by this provider.
fn scene_bank_count(&self) -> usize;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::{ParallaxFactor, SceneLayer};
use crate::tile::Tile;
use crate::tilemap::TileMap;
struct TestRenderResources {
glyph_banks: Vec<Option<GlyphBank>>,
scene_banks: Vec<Option<SceneBank>>,
}
impl RenderResourceAccess for TestRenderResources {
fn with_glyph_bank<R>(
&self,
bank_id: usize,
read: impl FnOnce(&GlyphBank) -> R,
) -> Option<R> {
self.glyph_banks.get(bank_id)?.as_ref().map(read)
}
fn glyph_bank_count(&self) -> usize {
self.glyph_banks.len()
}
fn with_scene_bank<R>(
&self,
bank_id: usize,
read: impl FnOnce(&SceneBank) -> R,
) -> Option<R> {
self.scene_banks.get(bank_id)?.as_ref().map(read)
}
fn scene_bank_count(&self) -> usize {
self.scene_banks.len()
}
}
fn scene_bank(tile_size: TileSize) -> SceneBank {
let layer = SceneLayer {
active: true,
glyph_asset_id: 7,
tile_size,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap {
width: 1,
height: 1,
tiles: vec![Tile {
active: true,
glyph: Glyph { glyph_id: 3, palette_id: 0 },
flip_x: false,
flip_y: false,
}],
},
};
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
}
#[test]
fn reads_resident_resources_by_id() {
let resources = TestRenderResources {
glyph_banks: vec![None, Some(GlyphBank::new(TileSize::Size16, 16, 16)), None],
scene_banks: vec![Some(scene_bank(TileSize::Size8)), None],
};
assert_eq!(resources.glyph_bank_count(), 3);
assert_eq!(resources.scene_bank_count(), 2);
assert_eq!(resources.with_glyph_bank(1, |bank| bank.tile_size), Some(TileSize::Size16));
assert_eq!(
resources.with_scene_bank(0, |bank| bank.layers[0].tile_size),
Some(TileSize::Size8)
);
}
#[test]
fn missing_resources_return_none() {
let resources = TestRenderResources { glyph_banks: vec![None], scene_banks: vec![None] };
assert_eq!(resources.with_glyph_bank(0, |bank| bank.width), None);
assert_eq!(resources.with_glyph_bank(9, |bank| bank.width), None);
assert_eq!(resources.with_scene_bank(0, |bank| bank.layers[0].active), None);
assert_eq!(resources.with_scene_bank(9, |bank| bank.layers[0].active), None);
}
}

View File

@ -1,241 +0,0 @@
use crate::app_mode::AppMode;
use crate::color::Color;
use crate::primitives::Rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct FrameId(pub u64);
impl FrameId {
pub const ZERO: Self = Self(0);
pub const fn new(value: u64) -> Self {
Self(value)
}
pub const fn get(self) -> u64 {
self.0
}
pub const fn next(self) -> Self {
Self(self.0.wrapping_add(1))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderSubmission {
pub frame_id: FrameId,
pub app_mode: AppMode,
pub ownership: RenderOwnership,
pub packet: RenderSubmissionPacket,
}
impl RenderSubmission {
pub const fn game2d(frame_id: FrameId, packet: Game2DFramePacket) -> Self {
Self {
frame_id,
app_mode: AppMode::Game,
ownership: RenderOwnership::new(0, AppMode::Game, 0),
packet: RenderSubmissionPacket::Game2D(packet),
}
}
pub const fn shell_ui(frame_id: FrameId, packet: ShellUiFramePacket) -> Self {
Self {
frame_id,
app_mode: AppMode::Shell,
ownership: RenderOwnership::new(0, AppMode::Shell, 0),
packet: RenderSubmissionPacket::ShellUi(packet),
}
}
pub const fn with_ownership(mut self, ownership: RenderOwnership) -> Self {
self.ownership = ownership;
self
}
pub const fn is_coherent(&self) -> bool {
matches!(
(self.app_mode, &self.packet),
(AppMode::Game, RenderSubmissionPacket::Game2D(_))
| (AppMode::Shell, RenderSubmissionPacket::ShellUi(_))
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOwnership {
pub epoch: u64,
pub app_mode: AppMode,
pub app_id: u32,
}
impl RenderOwnership {
pub const fn new(epoch: u64, app_mode: AppMode, app_id: u32) -> Self {
Self { epoch, app_mode, app_id }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenderSubmissionPacket {
Game2D(Game2DFramePacket),
ShellUi(ShellUiFramePacket),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Game2DFramePacket {
pub composer: ComposerFramePacket,
pub gfx2d: Vec<Gfx2dCommand>,
}
impl Game2DFramePacket {
pub fn new(composer: ComposerFramePacket, gfx2d: Vec<Gfx2dCommand>) -> Self {
Self { composer, gfx2d }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellUiFramePacket {
pub commands: Vec<GfxUiCommand>,
}
impl ShellUiFramePacket {
pub fn new(commands: Vec<GfxUiCommand>) -> Self {
Self { commands }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ComposerFramePacket {
pub bound_scene: Option<BoundScenePacket>,
pub camera: Camera2D,
pub sprites: Vec<GameSpritePacket>,
pub hud: HudPacket,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BoundScenePacket {
pub bank_id: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Camera2D {
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameSpritePacket {
pub glyph_id: i32,
pub palette_id: i32,
pub x: i32,
pub y: i32,
pub layer: i32,
pub bank_id: i32,
pub flip_x: bool,
pub flip_y: bool,
pub priority: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HudPacket {
pub commands: Vec<HudCommand>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HudCommand {
Clear { color: Color },
FillRect { rect: Rect, color: Color },
DrawText { x: i32, y: i32, text: String, color: Color },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gfx2dCommand {
Clear { color: Color },
FillRect { rect: Rect, color: Color },
DrawLine { x0: i32, y0: i32, x1: i32, y1: i32, color: Color },
DrawCircle { x: i32, y: i32, radius: i32, color: Color },
DrawDisc { x: i32, y: i32, radius: i32, border_color: Color, fill_color: Color },
DrawSquare { rect: Rect, border_color: Color, fill_color: Color },
DrawText { x: i32, y: i32, text: String, color: Color },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GfxUiCommand {
Clear { color: Color },
FillRect { rect: Rect, color: Color },
DrawLine { x0: i32, y0: i32, x1: i32, y1: i32, color: Color },
DrawCircle { x: i32, y: i32, radius: i32, color: Color },
DrawDisc { x: i32, y: i32, radius: i32, border_color: Color, fill_color: Color },
DrawSquare { rect: Rect, border_color: Color, fill_color: Color },
DrawText { x: i32, y: i32, text: String, color: Color },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_pin_app_mode_to_packet_kind() {
let game = RenderSubmission::game2d(FrameId::new(7), Game2DFramePacket::default());
assert_eq!(game.app_mode, AppMode::Game);
assert!(matches!(game.packet, RenderSubmissionPacket::Game2D(_)));
assert!(game.is_coherent());
let shell =
RenderSubmission::shell_ui(FrameId::new(8), ShellUiFramePacket::new(Vec::new()));
assert_eq!(shell.app_mode, AppMode::Shell);
assert!(matches!(shell.packet, RenderSubmissionPacket::ShellUi(_)));
assert!(shell.is_coherent());
}
#[test]
fn coherence_check_rejects_mismatched_envelope() {
let submission = RenderSubmission {
frame_id: FrameId::new(1),
app_mode: AppMode::Game,
ownership: RenderOwnership::new(0, AppMode::Game, 0),
packet: RenderSubmissionPacket::ShellUi(ShellUiFramePacket::new(Vec::new())),
};
assert!(!submission.is_coherent());
}
#[test]
fn ownership_can_be_stamped_by_runtime() {
let submission = RenderSubmission::game2d(FrameId::new(3), Game2DFramePacket::default())
.with_ownership(RenderOwnership::new(9, AppMode::Game, 42));
assert_eq!(submission.ownership, RenderOwnership::new(9, AppMode::Game, 42));
}
#[test]
fn closed_submission_owns_command_payload() {
let mut commands = vec![Gfx2dCommand::DrawText {
x: 1,
y: 2,
text: "before".to_string(),
color: Color::WHITE,
}];
let submission = RenderSubmission::game2d(
FrameId::new(2),
Game2DFramePacket::new(Default::default(), commands.clone()),
);
commands.push(Gfx2dCommand::Clear { color: Color::BLACK });
let RenderSubmissionPacket::Game2D(packet) = submission.packet else {
panic!("expected game packet");
};
assert_eq!(packet.gfx2d.len(), 1);
assert_eq!(
packet.gfx2d[0],
Gfx2dCommand::DrawText { x: 1, y: 2, text: "before".to_string(), color: Color::WHITE }
);
}
#[test]
fn frame_id_advances_monotonically_with_wrapping_arithmetic() {
assert_eq!(FrameId::new(41).next(), FrameId::new(42));
assert_eq!(FrameId::new(u64::MAX).next(), FrameId::ZERO);
}
}

View File

@ -1,203 +0,0 @@
use std::fmt;
use crate::{OwnedRgba8888Frame, RenderSubmission};
/// Typed failure surface for the real render worker and its backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderWorkerError {
BackendUnavailable,
RenderFailed,
PublishFailed,
PresentFailed,
ShutdownTimeout,
StaleSubmissionDiscarded,
WorkerPanic,
InternalFailure,
}
impl fmt::Display for RenderWorkerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BackendUnavailable => f.write_str("render backend unavailable"),
Self::RenderFailed => f.write_str("render failed"),
Self::PublishFailed => f.write_str("render publish failed"),
Self::PresentFailed => f.write_str("render present failed"),
Self::ShutdownTimeout => f.write_str("render worker shutdown timed out"),
Self::StaleSubmissionDiscarded => f.write_str("stale render submission discarded"),
Self::WorkerPanic => f.write_str("render worker panicked"),
Self::InternalFailure => f.write_str("internal render worker failure"),
}
}
}
impl std::error::Error for RenderWorkerError {}
pub trait RenderWorkerBackend: Send + 'static {
fn render(
&self,
submission: &RenderSubmission,
) -> Result<OwnedRgba8888Frame, RenderWorkerError>;
}
pub trait RenderWorkerFrameSink: Send + 'static {
fn publish(&self, frame: OwnedRgba8888Frame) -> Result<(), RenderWorkerError>;
}
/// Snapshot of render worker handoff, render, publish, and shutdown counters.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderWorkerTelemetry {
pub produced_submissions: u64,
pub replaced_before_consume: u64,
pub consumed_submissions: u64,
pub published_frames: u64,
pub presented_frames: u64,
pub repeated_frames: u64,
pub stale_submission_discards: u64,
pub render_failures: u64,
pub publish_failures: u64,
pub present_failures: u64,
pub shutdown_timeouts: u64,
pub worker_panics: u64,
pub last_produced_frame_id: u64,
pub last_consumed_frame_id: u64,
pub last_published_frame_id: u64,
pub last_presented_frame_id: u64,
pub last_dropped_frame_id: u64,
pub last_error_frame_id: u64,
pub active_render_epoch: u64,
}
impl RenderWorkerTelemetry {
pub fn record_produced(&mut self, frame_id: u64) {
self.produced_submissions += 1;
self.last_produced_frame_id = frame_id;
}
pub fn record_replaced_before_consume(&mut self, frame_id: u64) {
self.replaced_before_consume += 1;
self.last_dropped_frame_id = frame_id;
}
pub fn record_consumed(&mut self, frame_id: u64) {
self.consumed_submissions += 1;
self.last_consumed_frame_id = frame_id;
}
pub fn record_published(&mut self, frame_id: u64) {
self.published_frames += 1;
self.last_published_frame_id = frame_id;
}
pub fn record_presented(&mut self, frame_id: u64) {
self.presented_frames += 1;
self.last_presented_frame_id = frame_id;
}
pub fn record_repeated_frame(&mut self, frame_id: u64) {
self.repeated_frames += 1;
self.last_presented_frame_id = frame_id;
}
pub fn record_stale_submission_discarded(&mut self, frame_id: u64) {
self.stale_submission_discards += 1;
self.last_dropped_frame_id = frame_id;
}
pub fn record_error(&mut self, error: RenderWorkerError, frame_id: u64) {
match error {
RenderWorkerError::BackendUnavailable | RenderWorkerError::RenderFailed => {
self.render_failures += 1;
}
RenderWorkerError::PublishFailed => {
self.publish_failures += 1;
}
RenderWorkerError::PresentFailed => {
self.present_failures += 1;
}
RenderWorkerError::ShutdownTimeout => {
self.shutdown_timeouts += 1;
}
RenderWorkerError::WorkerPanic => {
self.worker_panics += 1;
}
RenderWorkerError::StaleSubmissionDiscarded => {
self.stale_submission_discards += 1;
self.last_dropped_frame_id = frame_id;
}
RenderWorkerError::InternalFailure => {
self.render_failures += 1;
}
}
self.last_error_frame_id = frame_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_worker_error_is_copy_equatable_and_debuggable() {
let error = RenderWorkerError::ShutdownTimeout;
let copied = error;
assert_eq!(copied, RenderWorkerError::ShutdownTimeout);
assert_eq!(format!("{:?}", error), "ShutdownTimeout");
assert_eq!(error.to_string(), "render worker shutdown timed out");
}
#[test]
fn render_worker_telemetry_defaults_to_zero() {
assert_eq!(
RenderWorkerTelemetry::default(),
RenderWorkerTelemetry {
produced_submissions: 0,
replaced_before_consume: 0,
consumed_submissions: 0,
published_frames: 0,
presented_frames: 0,
repeated_frames: 0,
stale_submission_discards: 0,
render_failures: 0,
publish_failures: 0,
present_failures: 0,
shutdown_timeouts: 0,
worker_panics: 0,
last_produced_frame_id: 0,
last_consumed_frame_id: 0,
last_published_frame_id: 0,
last_presented_frame_id: 0,
last_dropped_frame_id: 0,
last_error_frame_id: 0,
active_render_epoch: 0,
}
);
}
#[test]
fn render_worker_telemetry_records_counters_and_frame_ids() {
let mut telemetry = RenderWorkerTelemetry::default();
telemetry.record_produced(10);
telemetry.record_replaced_before_consume(10);
telemetry.record_consumed(11);
telemetry.record_published(11);
telemetry.record_presented(11);
telemetry.record_repeated_frame(11);
telemetry.record_error(RenderWorkerError::PublishFailed, 12);
telemetry.record_error(RenderWorkerError::WorkerPanic, 13);
telemetry.record_stale_submission_discarded(14);
assert_eq!(telemetry.produced_submissions, 1);
assert_eq!(telemetry.replaced_before_consume, 1);
assert_eq!(telemetry.consumed_submissions, 1);
assert_eq!(telemetry.published_frames, 1);
assert_eq!(telemetry.presented_frames, 1);
assert_eq!(telemetry.repeated_frames, 1);
assert_eq!(telemetry.publish_failures, 1);
assert_eq!(telemetry.worker_panics, 1);
assert_eq!(telemetry.stale_submission_discards, 1);
assert_eq!(telemetry.last_error_frame_id, 13);
assert_eq!(telemetry.last_dropped_frame_id, 14);
}
}

View File

@ -1,56 +0,0 @@
use crate::scene_layer::SceneLayer;
#[derive(Clone, Debug)]
pub struct SceneBank {
pub layers: [SceneLayer; 4],
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::ParallaxFactor;
use crate::tile::Tile;
use crate::tilemap::TileMap;
fn layer(
glyph_asset_id: AssetId,
parallax_x: f32,
parallax_y: f32,
glyph_id: u16,
) -> SceneLayer {
SceneLayer {
active: true,
glyph_asset_id,
tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap {
width: 1,
height: 1,
tiles: vec![Tile {
active: true,
glyph: Glyph { glyph_id, palette_id: 1 },
flip_x: false,
flip_y: false,
}],
},
}
}
#[test]
fn scene_bank_owns_exactly_four_layers() {
let scene = SceneBank {
layers: [
layer(0, 1.0, 1.0, 10),
layer(1, 0.5, 1.0, 11),
layer(2, 1.0, 0.5, 12),
layer(3, 0.25, 0.25, 13),
],
};
assert_eq!(scene.layers.len(), 4);
assert_eq!(scene.layers[3].tilemap.tiles[0].glyph.glyph_id, 13);
}
}

View File

@ -1,60 +0,0 @@
use crate::asset::AssetId;
use crate::glyph_bank::TileSize;
use crate::tilemap::TileMap;
#[derive(Clone, Copy, Debug)]
pub struct ParallaxFactor {
pub x: f32,
pub y: f32,
}
#[derive(Clone, Debug)]
pub struct SceneLayer {
pub active: bool,
pub glyph_asset_id: AssetId,
pub tile_size: TileSize,
pub parallax_factor: ParallaxFactor,
pub tilemap: TileMap,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::tile::Tile;
#[test]
fn scene_layer_preserves_parallax_factor_and_tilemap_ownership() {
let layer = SceneLayer {
active: true,
glyph_asset_id: 7,
tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: 0.5, y: 0.75 },
tilemap: TileMap {
width: 2,
height: 1,
tiles: vec![
Tile {
active: true,
glyph: Glyph { glyph_id: 21, palette_id: 3 },
flip_x: false,
flip_y: false,
},
Tile {
active: false,
glyph: Glyph { glyph_id: 22, palette_id: 4 },
flip_x: true,
flip_y: false,
},
],
},
};
assert_eq!(layer.glyph_asset_id, 7);
assert_eq!(layer.parallax_factor.x, 0.5);
assert_eq!(layer.parallax_factor.y, 0.75);
assert_eq!(layer.tilemap.width, 2);
assert_eq!(layer.tilemap.tiles[1].glyph.glyph_id, 22);
assert!(layer.tilemap.tiles[1].flip_x);
}
}

View File

@ -1,562 +0,0 @@
use crate::asset::AssetId;
use crate::glyph_bank::TileSize;
use crate::scene_bank::SceneBank;
use crate::scene_layer::SceneLayer;
use crate::tile::Tile;
const FLAG_FLIP_X: u8 = 0b0000_0001;
const FLAG_FLIP_Y: u8 = 0b0000_0010;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CachedTileEntry {
pub active: bool,
pub glyph_id: u16,
pub palette_id: u8,
pub flags: u8,
pub glyph_asset_id: AssetId,
}
impl CachedTileEntry {
pub fn flip_x(self) -> bool {
(self.flags & FLAG_FLIP_X) != 0
}
pub fn flip_y(self) -> bool {
(self.flags & FLAG_FLIP_Y) != 0
}
fn from_tile(layer: &SceneLayer, tile: Tile) -> Self {
let mut flags = 0_u8;
if tile.flip_x {
flags |= FLAG_FLIP_X;
}
if tile.flip_y {
flags |= FLAG_FLIP_Y;
}
Self {
active: tile.active,
glyph_id: tile.glyph.glyph_id,
palette_id: tile.glyph.palette_id,
flags,
glyph_asset_id: layer.glyph_asset_id,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ViewportRegion {
pub x: usize,
pub y: usize,
pub width: usize,
pub height: usize,
}
impl ViewportRegion {
pub const fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
Self { x, y, width, height }
}
}
#[derive(Clone, Debug)]
pub struct SceneViewportLayerCache {
width: usize,
height: usize,
logical_origin_x: i32,
logical_origin_y: i32,
ring_origin_x: usize,
ring_origin_y: usize,
pub glyph_asset_id: AssetId,
pub tile_size: TileSize,
entries: Vec<CachedTileEntry>,
pub valid: bool,
}
impl SceneViewportLayerCache {
pub fn new(layer: &SceneLayer, width: usize, height: usize) -> Self {
Self {
width,
height,
logical_origin_x: 0,
logical_origin_y: 0,
ring_origin_x: 0,
ring_origin_y: 0,
glyph_asset_id: layer.glyph_asset_id,
tile_size: layer.tile_size,
entries: vec![CachedTileEntry::default(); width * height],
valid: false,
}
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
pub fn logical_origin(&self) -> (i32, i32) {
(self.logical_origin_x, self.logical_origin_y)
}
pub fn ring_origin(&self) -> (usize, usize) {
(self.ring_origin_x, self.ring_origin_y)
}
pub fn entry(&self, cache_x: usize, cache_y: usize) -> CachedTileEntry {
self.entries[self.physical_index(cache_x, cache_y)]
}
pub fn invalidate_all(&mut self) {
self.entries.fill(CachedTileEntry::default());
self.valid = false;
}
pub fn move_window_to(&mut self, origin_x: i32, origin_y: i32) {
let delta_x = origin_x - self.logical_origin_x;
let delta_y = origin_y - self.logical_origin_y;
self.logical_origin_x = origin_x;
self.logical_origin_y = origin_y;
self.ring_origin_x = Self::wrapped_origin(self.ring_origin_x, delta_x, self.width);
self.ring_origin_y = Self::wrapped_origin(self.ring_origin_y, delta_y, self.height);
}
pub fn move_window_by(&mut self, delta_x: i32, delta_y: i32) {
self.move_window_to(self.logical_origin_x + delta_x, self.logical_origin_y + delta_y);
}
pub fn refresh_line(&mut self, layer: &SceneLayer, cache_y: usize) {
self.refresh_region(layer, ViewportRegion::new(0, cache_y, self.width, 1));
}
pub fn refresh_column(&mut self, layer: &SceneLayer, cache_x: usize) {
self.refresh_region(layer, ViewportRegion::new(cache_x, 0, 1, self.height));
}
pub fn refresh_region(&mut self, layer: &SceneLayer, region: ViewportRegion) {
self.glyph_asset_id = layer.glyph_asset_id;
self.tile_size = layer.tile_size;
let max_x = region.x.saturating_add(region.width).min(self.width);
let max_y = region.y.saturating_add(region.height).min(self.height);
for cache_y in region.y..max_y {
for cache_x in region.x..max_x {
let entry = self.materialize_entry(layer, cache_x, cache_y);
let idx = self.physical_index(cache_x, cache_y);
self.entries[idx] = entry;
}
}
self.valid = true;
}
pub fn refresh_all(&mut self, layer: &SceneLayer) {
self.refresh_region(layer, ViewportRegion::new(0, 0, self.width, self.height));
}
fn materialize_entry(
&self,
layer: &SceneLayer,
cache_x: usize,
cache_y: usize,
) -> CachedTileEntry {
let scene_x = self.logical_origin_x + cache_x as i32;
let scene_y = self.logical_origin_y + cache_y as i32;
if scene_x < 0 || scene_y < 0 {
return CachedTileEntry::default();
}
let tile_x = scene_x as usize;
let tile_y = scene_y as usize;
if tile_x >= layer.tilemap.width || tile_y >= layer.tilemap.height {
return CachedTileEntry::default();
}
let tile = layer.tilemap.tiles[tile_y * layer.tilemap.width + tile_x];
CachedTileEntry::from_tile(layer, tile)
}
fn physical_index(&self, cache_x: usize, cache_y: usize) -> usize {
let physical_x = (self.ring_origin_x + cache_x) % self.width;
let physical_y = (self.ring_origin_y + cache_y) % self.height;
physical_y * self.width + physical_x
}
fn wrapped_origin(current: usize, delta: i32, span: usize) -> usize {
if span == 0 {
return 0;
}
let span_i32 = span as i32;
let current_i32 = current as i32;
(current_i32 + delta).rem_euclid(span_i32) as usize
}
}
#[derive(Clone, Debug)]
pub struct SceneViewportCache {
width: usize,
height: usize,
pub layers: [SceneViewportLayerCache; 4],
}
impl SceneViewportCache {
pub fn new(scene: &SceneBank, width: usize, height: usize) -> Self {
Self {
width,
height,
layers: std::array::from_fn(|i| {
SceneViewportLayerCache::new(&scene.layers[i], width, height)
}),
}
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
pub fn invalidate_all(&mut self) {
for layer in &mut self.layers {
layer.invalidate_all();
}
}
pub fn move_layer_window_to(&mut self, layer_idx: usize, origin_x: i32, origin_y: i32) {
self.layers[layer_idx].move_window_to(origin_x, origin_y);
}
pub fn move_layer_window_by(&mut self, layer_idx: usize, delta_x: i32, delta_y: i32) {
self.layers[layer_idx].move_window_by(delta_x, delta_y);
}
pub fn refresh_layer_line(&mut self, scene: &SceneBank, layer_idx: usize, cache_y: usize) {
self.layers[layer_idx].refresh_line(&scene.layers[layer_idx], cache_y);
}
pub fn refresh_layer_column(&mut self, scene: &SceneBank, layer_idx: usize, cache_x: usize) {
self.layers[layer_idx].refresh_column(&scene.layers[layer_idx], cache_x);
}
pub fn refresh_layer_region(
&mut self,
scene: &SceneBank,
layer_idx: usize,
region: ViewportRegion,
) {
self.layers[layer_idx].refresh_region(&scene.layers[layer_idx], region);
}
pub fn refresh_layer_all(&mut self, scene: &SceneBank, layer_idx: usize) {
self.layers[layer_idx].refresh_all(&scene.layers[layer_idx]);
}
pub fn materialize_all_layers(&mut self, scene: &SceneBank) {
for layer_idx in 0..self.layers.len() {
self.refresh_layer_all(scene, layer_idx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::ParallaxFactor;
use crate::tile::Tile;
use crate::tilemap::TileMap;
fn make_tile(glyph_id: u16, palette_id: u8, flip_x: bool, flip_y: bool) -> Tile {
Tile { active: true, glyph: Glyph { glyph_id, palette_id }, flip_x, flip_y }
}
fn make_layer(glyph_asset_id: AssetId, base_glyph: u16) -> SceneLayer {
let mut tiles = Vec::new();
for y in 0..4 {
for x in 0..4 {
tiles.push(make_tile(base_glyph + (y * 4 + x) as u16, 1, x % 2 == 0, y % 2 == 1));
}
}
SceneLayer {
active: true,
glyph_asset_id,
tile_size: TileSize::Size16,
parallax_factor: ParallaxFactor { x: 1.0, y: 1.0 },
tilemap: TileMap { width: 4, height: 4, tiles },
}
}
fn make_scene() -> SceneBank {
SceneBank {
layers: [
make_layer(1, 100),
make_layer(2, 200),
make_layer(3, 300),
make_layer(4, 400),
],
}
}
#[test]
fn layer_cache_wraps_ring_origin_under_window_movement() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.move_layer_window_by(0, 1, 2);
assert_eq!(cache.layers[0].logical_origin(), (1, 2));
assert_eq!(cache.layers[0].ring_origin(), (1, 2));
cache.move_layer_window_by(0, 3, 2);
assert_eq!(cache.layers[0].logical_origin(), (4, 4));
assert_eq!(cache.layers[0].ring_origin(), (1, 1));
}
#[test]
fn layer_cache_wraps_ring_origin_for_negative_and_large_movements() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.move_layer_window_by(0, -1, -4);
assert_eq!(cache.layers[0].logical_origin(), (-1, -4));
assert_eq!(cache.layers[0].ring_origin(), (2, 2));
cache.move_layer_window_by(0, 7, 8);
assert_eq!(cache.layers[0].logical_origin(), (6, 4));
assert_eq!(cache.layers[0].ring_origin(), (0, 1));
}
#[test]
fn move_window_to_matches_incremental_ring_movement() {
let scene = make_scene();
let mut direct = SceneViewportCache::new(&scene, 4, 4);
let mut incremental = SceneViewportCache::new(&scene, 4, 4);
direct.move_layer_window_to(0, 9, -6);
incremental.move_layer_window_by(0, 5, -2);
incremental.move_layer_window_by(0, 4, -4);
assert_eq!(direct.layers[0].logical_origin(), incremental.layers[0].logical_origin());
assert_eq!(direct.layers[0].ring_origin(), incremental.layers[0].ring_origin());
}
#[test]
fn cache_entry_fields_are_derived_from_scene_tiles() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 2, 2);
cache.refresh_layer_all(&scene, 0);
let entry = cache.layers[0].entry(1, 1);
assert!(entry.active);
assert_eq!(entry.glyph_id, 105);
assert_eq!(entry.palette_id, 1);
assert_eq!(entry.glyph_asset_id, 1);
assert!(!entry.flip_x());
assert!(entry.flip_y());
}
#[test]
fn line_refresh_only_updates_the_requested_line() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.refresh_layer_line(&scene, 0, 1);
assert_eq!(cache.layers[0].entry(0, 0), CachedTileEntry::default());
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 104);
assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 106);
assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default());
}
#[test]
fn column_refresh_only_updates_the_requested_column() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.refresh_layer_column(&scene, 1, 2);
assert_eq!(cache.layers[1].entry(0, 0), CachedTileEntry::default());
assert_eq!(cache.layers[1].entry(2, 0).glyph_id, 202);
assert_eq!(cache.layers[1].entry(2, 2).glyph_id, 210);
assert_eq!(cache.layers[1].entry(1, 2), CachedTileEntry::default());
}
#[test]
fn region_refresh_only_updates_the_requested_area() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.refresh_layer_region(&scene, 2, ViewportRegion::new(1, 1, 2, 2));
assert_eq!(cache.layers[2].entry(0, 0), CachedTileEntry::default());
assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305);
assert_eq!(cache.layers[2].entry(2, 2).glyph_id, 310);
assert_eq!(cache.layers[2].entry(0, 2), CachedTileEntry::default());
}
#[test]
fn scene_swap_invalidation_clears_all_layers() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 2, 2);
cache.materialize_all_layers(&scene);
cache.invalidate_all();
for layer in &cache.layers {
assert!(!layer.valid);
for y in 0..cache.height() {
for x in 0..cache.width() {
assert_eq!(layer.entry(x, y), CachedTileEntry::default());
}
}
}
}
#[test]
fn corner_style_region_update_does_not_touch_outside_tiles() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 4, 4);
cache.materialize_all_layers(&scene);
let before = cache.layers[3].entry(1, 1);
cache.layers[3].invalidate_all();
cache.refresh_layer_region(&scene, 3, ViewportRegion::new(2, 2, 2, 2));
assert_eq!(cache.layers[3].entry(0, 0), CachedTileEntry::default());
assert_eq!(cache.layers[3].entry(1, 1), CachedTileEntry::default());
assert_ne!(cache.layers[3].entry(2, 2), CachedTileEntry::default());
assert_eq!(before.glyph_id, 405);
assert_eq!(cache.layers[3].entry(3, 3).glyph_id, 415);
}
#[test]
fn refresh_after_wrapped_window_move_materializes_new_logical_tiles() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.refresh_layer_all(&scene, 0);
cache.move_layer_window_to(0, 1, 2);
cache.refresh_layer_all(&scene, 0);
assert_eq!(cache.layers[0].logical_origin(), (1, 2));
assert_eq!(cache.layers[0].ring_origin(), (1, 2));
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 109);
assert_eq!(cache.layers[0].entry(1, 0).glyph_id, 110);
assert_eq!(cache.layers[0].entry(2, 0).glyph_id, 111);
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 113);
assert_eq!(cache.layers[0].entry(2, 1).glyph_id, 115);
assert_eq!(cache.layers[0].entry(0, 2), CachedTileEntry::default());
}
#[test]
fn partial_refresh_uses_wrapped_physical_slots_after_window_move() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
cache.move_layer_window_to(0, 1, 0);
cache.refresh_layer_column(&scene, 0, 0);
assert_eq!(cache.layers[0].ring_origin(), (1, 0));
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 101);
assert_eq!(cache.layers[0].entry(0, 1).glyph_id, 105);
assert_eq!(cache.layers[0].entry(0, 2).glyph_id, 109);
assert_eq!(cache.layers[0].entry(1, 0), CachedTileEntry::default());
assert_eq!(cache.layers[0].entry(2, 0), CachedTileEntry::default());
}
#[test]
fn out_of_bounds_logical_origins_materialize_default_entries_after_wrap() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 2, 2);
cache.move_layer_window_to(0, -2, 3);
cache.refresh_layer_all(&scene, 0);
for y in 0..2 {
for x in 0..2 {
assert_eq!(cache.layers[0].entry(x, y), CachedTileEntry::default());
}
}
}
#[test]
fn ringbuffer_preserves_logical_tile_mapping_across_long_mixed_movements() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 3, 3);
let motions = [
(1, 0),
(0, 1),
(2, 2),
(-1, 0),
(0, -2),
(4, 1),
(-3, 3),
(5, -4),
(-6, 2),
(3, -3),
(7, 7),
(-8, -5),
];
for &(dx, dy) in &motions {
cache.move_layer_window_by(0, dx, dy);
cache.refresh_layer_all(&scene, 0);
let (origin_x, origin_y) = cache.layers[0].logical_origin();
for cache_y in 0..cache.height() {
for cache_x in 0..cache.width() {
let expected_scene_x = origin_x + cache_x as i32;
let expected_scene_y = origin_y + cache_y as i32;
let expected = if expected_scene_x < 0
|| expected_scene_y < 0
|| expected_scene_x as usize >= scene.layers[0].tilemap.width
|| expected_scene_y as usize >= scene.layers[0].tilemap.height
{
CachedTileEntry::default()
} else {
let tile_x = expected_scene_x as usize;
let tile_y = expected_scene_y as usize;
let tile = scene.layers[0].tilemap.tiles
[tile_y * scene.layers[0].tilemap.width + tile_x];
CachedTileEntry::from_tile(&scene.layers[0], tile)
};
assert_eq!(
cache.layers[0].entry(cache_x, cache_y),
expected,
"mismatch at logical origin ({}, {}), cache ({}, {})",
origin_x,
origin_y,
cache_x,
cache_y
);
}
}
}
}
#[test]
fn materialization_populates_all_four_layers() {
let scene = make_scene();
let mut cache = SceneViewportCache::new(&scene, 2, 2);
cache.materialize_all_layers(&scene);
assert_eq!(cache.layers[0].entry(0, 0).glyph_id, 100);
assert_eq!(cache.layers[1].entry(0, 0).glyph_id, 200);
assert_eq!(cache.layers[2].entry(1, 1).glyph_id, 305);
assert_eq!(cache.layers[3].entry(1, 0).glyph_id, 401);
}
}

View File

@ -1,537 +0,0 @@
use crate::glyph_bank::TileSize;
use crate::scene_bank::SceneBank;
use crate::scene_viewport_cache::ViewportRegion;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TileAnchor {
pub x: i32,
pub y: i32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheRefreshRequest {
InvalidateLayer { layer_index: usize },
RefreshLine { layer_index: usize, cache_y: usize },
RefreshColumn { layer_index: usize, cache_x: usize },
RefreshRegion { layer_index: usize, region: ViewportRegion },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LayerCopyRequest {
pub layer_index: usize,
pub tile_size: TileSize,
pub viewport_width_px: i32,
pub viewport_height_px: i32,
pub source_offset_x_px: i32,
pub source_offset_y_px: i32,
pub cache_origin_tile_x: i32,
pub cache_origin_tile_y: i32,
pub camera_x_px: i32,
pub camera_y_px: i32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolverUpdate {
pub master_anchor: TileAnchor,
pub layer_anchors: [TileAnchor; 4],
pub refresh_requests: Vec<CacheRefreshRequest>,
pub copy_requests: [LayerCopyRequest; 4],
}
#[derive(Clone, Debug)]
pub struct SceneViewportResolver {
viewport_width_px: i32,
viewport_height_px: i32,
cache_width_tiles: usize,
cache_height_tiles: usize,
hysteresis_safe_px: i32,
hysteresis_trigger_px: i32,
initialized: bool,
master_anchor: TileAnchor,
layer_anchors: [TileAnchor; 4],
}
impl SceneViewportResolver {
pub fn new(
viewport_width_px: i32,
viewport_height_px: i32,
cache_width_tiles: usize,
cache_height_tiles: usize,
hysteresis_safe_px: i32,
hysteresis_trigger_px: i32,
) -> Self {
Self {
viewport_width_px,
viewport_height_px,
cache_width_tiles,
cache_height_tiles,
hysteresis_safe_px,
hysteresis_trigger_px,
initialized: false,
master_anchor: TileAnchor::default(),
layer_anchors: [TileAnchor::default(); 4],
}
}
pub fn reset_scene(&mut self) -> Vec<CacheRefreshRequest> {
self.initialized = false;
vec![
CacheRefreshRequest::InvalidateLayer { layer_index: 0 },
CacheRefreshRequest::InvalidateLayer { layer_index: 1 },
CacheRefreshRequest::InvalidateLayer { layer_index: 2 },
CacheRefreshRequest::InvalidateLayer { layer_index: 3 },
]
}
pub fn update(
&mut self,
scene: &SceneBank,
camera_x_px: i32,
camera_y_px: i32,
) -> ResolverUpdate {
let mut refresh_requests = Vec::new();
let camera_center_x = camera_x_px + self.viewport_width_px / 2;
let camera_center_y = camera_y_px + self.viewport_height_px / 2;
let layer_inputs: [(i32, i32, i32, i32, i32); 4] = std::array::from_fn(|i| {
let layer = &scene.layers[i];
let tile_size_px = layer.tile_size as i32;
let layer_camera_x_px = ((camera_x_px as f32) * layer.parallax_factor.x).floor() as i32;
let layer_camera_y_px = ((camera_y_px as f32) * layer.parallax_factor.y).floor() as i32;
let layer_center_x_px = layer_camera_x_px + self.viewport_width_px / 2;
let layer_center_y_px = layer_camera_y_px + self.viewport_height_px / 2;
(
tile_size_px,
layer_camera_x_px,
layer_camera_y_px,
layer_center_x_px,
layer_center_y_px,
)
});
if !self.initialized {
self.master_anchor = self.initial_anchor(
scene.layers[0].tilemap.width,
scene.layers[0].tilemap.height,
scene.layers[0].tile_size as i32,
camera_center_x,
camera_center_y,
);
for (layer_index, layer) in scene.layers.iter().enumerate() {
let (_, _, _, layer_center_x_px, layer_center_y_px) = layer_inputs[layer_index];
self.layer_anchors[layer_index] = self.initial_anchor(
layer.tilemap.width,
layer.tilemap.height,
layer.tile_size as i32,
layer_center_x_px,
layer_center_y_px,
);
refresh_requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(
0,
0,
self.cache_width_tiles,
self.cache_height_tiles,
),
});
}
self.initialized = true;
} else {
let layer0 = &scene.layers[0];
self.master_anchor = self.advance_anchor(
self.master_anchor,
camera_center_x,
camera_center_y,
layer0.tile_size as i32,
layer0.tilemap.width,
layer0.tilemap.height,
);
for (layer_index, layer) in scene.layers.iter().enumerate() {
let previous = self.layer_anchors[layer_index];
let (tile_size_px, _, _, layer_center_x_px, layer_center_y_px) =
layer_inputs[layer_index];
let next = self.advance_anchor(
previous,
layer_center_x_px,
layer_center_y_px,
tile_size_px,
layer.tilemap.width,
layer.tilemap.height,
);
self.layer_anchors[layer_index] = next;
self.emit_refresh_requests(layer_index, previous, next, &mut refresh_requests);
}
}
let copy_requests = std::array::from_fn(|layer_index| {
let layer = &scene.layers[layer_index];
let (tile_size_px, layer_camera_x_px, layer_camera_y_px, _, _) =
layer_inputs[layer_index];
let anchor = self.layer_anchors[layer_index];
let cache_origin_tile_x = anchor.x - (self.cache_width_tiles as i32 / 2);
let cache_origin_tile_y = anchor.y - (self.cache_height_tiles as i32 / 2);
let cache_origin_x_px = cache_origin_tile_x * tile_size_px;
let cache_origin_y_px = cache_origin_tile_y * tile_size_px;
LayerCopyRequest {
layer_index,
tile_size: layer.tile_size,
viewport_width_px: self.viewport_width_px,
viewport_height_px: self.viewport_height_px,
source_offset_x_px: layer_camera_x_px - cache_origin_x_px,
source_offset_y_px: layer_camera_y_px - cache_origin_y_px,
cache_origin_tile_x,
cache_origin_tile_y,
camera_x_px: layer_camera_x_px,
camera_y_px: layer_camera_y_px,
}
});
ResolverUpdate {
master_anchor: self.master_anchor,
layer_anchors: self.layer_anchors,
refresh_requests,
copy_requests,
}
}
fn initial_anchor(
&self,
scene_width_tiles: usize,
scene_height_tiles: usize,
tile_size_px: i32,
camera_center_x_px: i32,
camera_center_y_px: i32,
) -> TileAnchor {
let proposed = TileAnchor {
x: camera_center_x_px.div_euclid(tile_size_px),
y: camera_center_y_px.div_euclid(tile_size_px),
};
self.clamp_anchor(proposed, scene_width_tiles, scene_height_tiles)
}
fn advance_anchor(
&self,
current: TileAnchor,
camera_center_x_px: i32,
camera_center_y_px: i32,
tile_size_px: i32,
scene_width_tiles: usize,
scene_height_tiles: usize,
) -> TileAnchor {
let mut next = current;
loop {
let center_x_px = next.x * tile_size_px + tile_size_px / 2;
let drift_x = camera_center_x_px - center_x_px;
if drift_x.abs() <= self.hysteresis_safe_px {
break;
}
if drift_x > self.hysteresis_trigger_px {
next.x += 1;
continue;
}
if drift_x < -self.hysteresis_trigger_px {
next.x -= 1;
continue;
}
break;
}
loop {
let center_y_px = next.y * tile_size_px + tile_size_px / 2;
let drift_y = camera_center_y_px - center_y_px;
if drift_y.abs() <= self.hysteresis_safe_px {
break;
}
if drift_y > self.hysteresis_trigger_px {
next.y += 1;
continue;
}
if drift_y < -self.hysteresis_trigger_px {
next.y -= 1;
continue;
}
break;
}
self.clamp_anchor(next, scene_width_tiles, scene_height_tiles)
}
fn clamp_anchor(
&self,
proposed: TileAnchor,
scene_width_tiles: usize,
scene_height_tiles: usize,
) -> TileAnchor {
TileAnchor {
x: self.clamp_axis(proposed.x, scene_width_tiles, self.cache_width_tiles),
y: self.clamp_axis(proposed.y, scene_height_tiles, self.cache_height_tiles),
}
}
fn clamp_axis(&self, proposed: i32, scene_tiles: usize, cache_tiles: usize) -> i32 {
let half = (cache_tiles / 2) as i32;
if scene_tiles <= cache_tiles {
return half;
}
let min = half;
let max = (scene_tiles - cache_tiles) as i32 + half;
proposed.clamp(min, max)
}
fn emit_refresh_requests(
&self,
layer_index: usize,
previous: TileAnchor,
next: TileAnchor,
requests: &mut Vec<CacheRefreshRequest>,
) {
let delta_x = next.x - previous.x;
let delta_y = next.y - previous.y;
if delta_x == 0 && delta_y == 0 {
return;
}
if delta_x == 0 {
self.emit_line_requests(layer_index, delta_y, requests);
return;
}
if delta_y == 0 {
self.emit_column_requests(layer_index, delta_x, requests);
return;
}
self.emit_corner_region_requests(layer_index, delta_x, delta_y, requests);
}
fn emit_line_requests(
&self,
layer_index: usize,
delta_y: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let count = delta_y.unsigned_abs() as usize;
if delta_y > 0 {
for offset in 0..count {
requests.push(CacheRefreshRequest::RefreshLine {
layer_index,
cache_y: self.cache_height_tiles - count + offset,
});
}
} else {
for cache_y in 0..count {
requests.push(CacheRefreshRequest::RefreshLine { layer_index, cache_y });
}
}
}
fn emit_column_requests(
&self,
layer_index: usize,
delta_x: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let count = delta_x.unsigned_abs() as usize;
if delta_x > 0 {
for offset in 0..count {
requests.push(CacheRefreshRequest::RefreshColumn {
layer_index,
cache_x: self.cache_width_tiles - count + offset,
});
}
} else {
for cache_x in 0..count {
requests.push(CacheRefreshRequest::RefreshColumn { layer_index, cache_x });
}
}
}
fn emit_corner_region_requests(
&self,
layer_index: usize,
delta_x: i32,
delta_y: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let width = delta_x.unsigned_abs() as usize;
let height = delta_y.unsigned_abs() as usize;
let primary_x = if delta_x > 0 { self.cache_width_tiles - width } else { 0 };
requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(primary_x, 0, width, self.cache_height_tiles),
});
let secondary_y = if delta_y > 0 { self.cache_height_tiles - height } else { 0 };
let secondary_x = if delta_x > 0 { 0 } else { width };
let secondary_width = self.cache_width_tiles.saturating_sub(width);
if secondary_width > 0 {
requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(secondary_x, secondary_y, secondary_width, height),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::AssetId;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::{ParallaxFactor, SceneLayer};
use crate::tile::Tile;
use crate::tilemap::TileMap;
fn make_layer(
tile_size: TileSize,
parallax_x: f32,
parallax_y: f32,
width: usize,
height: usize,
) -> SceneLayer {
let mut tiles = Vec::new();
for i in 0..(width * height) {
tiles.push(Tile {
active: true,
glyph: Glyph { glyph_id: i as u16, palette_id: 0 },
flip_x: false,
flip_y: false,
});
}
SceneLayer {
active: true,
glyph_asset_id: 1 as AssetId,
tile_size,
parallax_factor: ParallaxFactor { x: parallax_x, y: parallax_y },
tilemap: TileMap { width, height, tiles },
}
}
fn make_scene() -> SceneBank {
SceneBank {
layers: [
make_layer(TileSize::Size16, 1.0, 1.0, 64, 64),
make_layer(TileSize::Size16, 0.5, 0.5, 64, 64),
make_layer(TileSize::Size16, 1.0, 0.75, 64, 64),
make_layer(TileSize::Size16, 0.25, 1.0, 64, 64),
],
}
}
#[test]
fn first_update_initializes_master_and_layer_anchors() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 0, 0);
assert_eq!(update.master_anchor, TileAnchor { x: 17, y: 10 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 17, y: 10 });
assert_eq!(update.layer_anchors[1], TileAnchor { x: 17, y: 10 });
assert_eq!(update.refresh_requests.len(), 4);
}
#[test]
fn per_layer_copy_requests_follow_parallax_factor() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 32, 48);
assert_eq!(update.copy_requests[0].camera_x_px, 32);
assert_eq!(update.copy_requests[0].camera_y_px, 48);
assert_eq!(update.copy_requests[1].camera_x_px, 16);
assert_eq!(update.copy_requests[1].camera_y_px, 24);
assert_eq!(update.copy_requests[3].camera_x_px, 8);
assert_eq!(update.copy_requests[3].camera_y_px, 48);
}
#[test]
fn hysteresis_prevents_small_back_and_forth_refresh_churn() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 8, 0);
assert!(update.refresh_requests.is_empty());
assert_eq!(update.master_anchor, TileAnchor { x: 17, y: 10 });
}
#[test]
fn repeated_high_speed_movement_advances_in_tile_steps() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 0);
assert!(update.master_anchor.x > 17);
assert!(update.refresh_requests.iter().any(|request| matches!(
request,
CacheRefreshRequest::RefreshColumn { layer_index: 0, .. }
| CacheRefreshRequest::RefreshRegion { layer_index: 0, .. }
)));
}
#[test]
fn anchors_clamp_near_scene_edges() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let update = resolver.update(&scene, 10_000, 10_000);
assert_eq!(update.master_anchor, TileAnchor { x: 46, y: 53 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 46, y: 53 });
}
#[test]
fn corner_trigger_converts_to_non_overlapping_region_requests() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 80);
let regions: Vec<_> = update
.refresh_requests
.iter()
.filter_map(|request| match request {
CacheRefreshRequest::RefreshRegion { layer_index: 0, region } => Some(*region),
_ => None,
})
.collect();
assert_eq!(regions.len(), 2);
assert_eq!(regions[0].x + regions[0].width, 35);
assert_eq!(regions[0].height, 21);
assert_eq!(regions[1].width, 34);
assert_eq!(regions[1].height, 2);
}
#[test]
fn reset_scene_requests_full_invalidation_for_all_layers() {
let mut resolver = SceneViewportResolver::new(480, 270, 35, 21, 12, 20);
let requests = resolver.reset_scene();
assert_eq!(requests.len(), 4);
assert!(
requests
.iter()
.all(|request| matches!(request, CacheRefreshRequest::InvalidateLayer { .. }))
);
}
}

View File

@ -1,171 +0,0 @@
mod domains;
mod registry;
mod resolver;
#[cfg(test)]
mod tests;
pub mod caps;
pub use resolver::{
DeclaredLoadError, LoadError, SyscallIdentity, SyscallResolved,
resolve_declared_program_syscalls, resolve_program_syscalls, resolve_syscall,
};
/// Enumeration of all System Calls (Syscalls) available in the Prometeu environment.
///
/// Syscalls are the primary mechanism for a program running in the Virtual Machine
/// to interact with the outside world (Hardware, OS, Filesystem).
///
/// Each Syscall has a unique 32-bit ID. The IDs are grouped by category:
/// - **0x0xxx**: System & OS Control
/// - **0x1xxx**: Game 2D primitives (`gfx2d`) and Shell UI primitives (`gfxui`)
/// - **0x11xx**: Frame Composer orchestration
/// - **0x2xxx**: Reserved for legacy input syscalls (disabled for v1 VM-owned input)
/// - **0x3xxx**: Audio (PCM & Mixing)
/// - **0x4xxx**: Filesystem (Sandboxed I/O)
/// - **0x5xxx**: Logging & Debugging
/// - **0x6xxx**: Asset Loading & Memory Banks
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum Syscall {
SystemHasCart = 0x0001,
GfxClear = 0x1001,
GfxFillRect = 0x1002,
GfxDrawLine = 0x1003,
GfxDrawCircle = 0x1004,
GfxDrawDisc = 0x1005,
GfxDrawSquare = 0x1006,
GfxDrawText = 0x1008,
GfxUiClear = 0x1201,
GfxUiFillRect = 0x1202,
GfxUiDrawLine = 0x1203,
GfxUiDrawCircle = 0x1204,
GfxUiDrawDisc = 0x1205,
GfxUiDrawSquare = 0x1206,
GfxUiDrawText = 0x1208,
ComposerBindScene = 0x1101,
ComposerUnbindScene = 0x1102,
ComposerSetCamera = 0x1103,
ComposerEmitSprite = 0x1104,
AudioPlaySample = 0x3001,
AudioPlay = 0x3002,
FsOpen = 0x4001,
FsRead = 0x4002,
FsWrite = 0x4003,
FsClose = 0x4004,
FsListDir = 0x4005,
FsExists = 0x4006,
FsDelete = 0x4007,
MemSlotCount = 0x4201,
MemSlotStat = 0x4202,
MemSlotRead = 0x4203,
MemSlotWrite = 0x4204,
MemSlotCommit = 0x4205,
MemSlotClear = 0x4206,
LogWrite = 0x5001,
LogWriteTag = 0x5002,
AssetLoad = 0x6001,
AssetStatus = 0x6002,
AssetCommit = 0x6003,
AssetCancel = 0x6004,
AssetBacklogInfo = 0x6005,
AssetBacklogPosition = 0x6006,
AssetBacklogMove = 0x6007,
AssetBacklogPromote = 0x6008,
AssetBacklogDemote = 0x6009,
AssetTargetStatus = 0x600A,
BankInfo = 0x6101,
}
/// Canonical metadata describing a syscall using the unified slot-based ABI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyscallMeta {
pub id: u32,
pub module: &'static str,
pub name: &'static str,
pub version: u16,
pub arg_slots: u8,
pub ret_slots: u16,
pub caps: CapFlags,
pub determinism: Determinism,
pub may_allocate: bool,
pub cost_hint: u32,
}
/// Bitflags representing capabilities required to invoke a syscall.
pub type CapFlags = u64;
/// Determinism flags for a syscall.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Determinism {
Unknown,
Deterministic,
NonDeterministic,
}
/// Pairing of a strongly-typed syscall and its metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyscallRegistryEntry {
pub syscall: Syscall,
pub meta: SyscallMeta,
}
impl SyscallRegistryEntry {
/// Starts the builder with mandatory fields and sensible default values.
pub const fn builder(syscall: Syscall, module: &'static str, name: &'static str) -> Self {
Self {
syscall,
meta: SyscallMeta {
id: syscall as u32,
module,
name,
version: 1, // Default for new syscalls
arg_slots: 0,
ret_slots: 0,
caps: 0,
determinism: Determinism::Deterministic,
may_allocate: false,
cost_hint: 1,
},
}
}
pub const fn version(mut self, n: u16) -> Self {
self.meta.version = n;
self
}
pub const fn args(mut self, n: u8) -> Self {
self.meta.arg_slots = n;
self
}
pub const fn rets(mut self, n: u16) -> Self {
self.meta.ret_slots = n;
self
}
pub const fn caps(mut self, caps: CapFlags) -> Self {
self.meta.caps = caps;
self
}
pub const fn non_deterministic(mut self) -> Self {
self.meta.determinism = Determinism::NonDeterministic;
self
}
pub const fn may_allocate(mut self) -> Self {
self.meta.may_allocate = true;
self
}
pub const fn cost(mut self, cost: u32) -> Self {
self.meta.cost_hint = cost;
self
}
}
pub fn meta_for(syscall: Syscall) -> &'static SyscallMeta {
registry::meta_for(syscall)
}

View File

@ -1,14 +0,0 @@
use super::CapFlags;
pub const NONE: CapFlags = 0;
pub const SYSTEM: CapFlags = 1 << 0;
pub const COMPOSER: CapFlags = 1 << 1;
pub const AUDIO: CapFlags = 1 << 2;
pub const FS: CapFlags = 1 << 3;
pub const LOG: CapFlags = 1 << 4;
pub const ASSET: CapFlags = 1 << 5;
pub const BANK: CapFlags = 1 << 6;
pub const GFX2D: CapFlags = 1 << 7;
pub const GFXUI: CapFlags = 1 << 8;
pub const GFX: CapFlags = COMPOSER | GFX2D;
pub const ALL: CapFlags = SYSTEM | COMPOSER | AUDIO | FS | LOG | ASSET | BANK | GFX2D | GFXUI;

View File

@ -1,63 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::AssetLoad, "asset", "load")
.args(2)
.rets(2)
.caps(caps::ASSET)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::AssetStatus, "asset", "status")
.args(1)
.rets(1)
.caps(caps::ASSET)
.non_deterministic(),
SyscallRegistryEntry::builder(Syscall::AssetCommit, "asset", "commit")
.args(1)
.rets(1)
.caps(caps::ASSET)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::AssetCancel, "asset", "cancel")
.args(1)
.rets(1)
.caps(caps::ASSET)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::AssetBacklogInfo, "asset", "backlog_info")
.args(0)
.rets(7)
.caps(caps::ASSET)
.non_deterministic()
.cost(5),
SyscallRegistryEntry::builder(Syscall::AssetBacklogPosition, "asset", "backlog_position")
.args(1)
.rets(4)
.caps(caps::ASSET)
.non_deterministic()
.cost(5),
SyscallRegistryEntry::builder(Syscall::AssetBacklogMove, "asset", "backlog_move")
.args(2)
.rets(1)
.caps(caps::ASSET)
.non_deterministic()
.cost(10),
SyscallRegistryEntry::builder(Syscall::AssetBacklogPromote, "asset", "backlog_promote")
.args(1)
.rets(1)
.caps(caps::ASSET)
.non_deterministic()
.cost(10),
SyscallRegistryEntry::builder(Syscall::AssetBacklogDemote, "asset", "backlog_demote")
.args(1)
.rets(1)
.caps(caps::ASSET)
.non_deterministic()
.cost(10),
SyscallRegistryEntry::builder(Syscall::AssetTargetStatus, "asset", "target_status")
.args(2)
.rets(6)
.caps(caps::ASSET)
.non_deterministic()
.cost(5),
];

View File

@ -1,14 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::AudioPlaySample, "audio", "play_sample")
.args(5)
.rets(1)
.caps(caps::AUDIO)
.cost(5),
SyscallRegistryEntry::builder(Syscall::AudioPlay, "audio", "play")
.args(7)
.rets(1)
.caps(caps::AUDIO)
.cost(5),
];

View File

@ -1,7 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] =
&[SyscallRegistryEntry::builder(Syscall::BankInfo, "bank", "info")
.args(1)
.rets(2)
.caps(caps::BANK)];

View File

@ -1,22 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::ComposerBindScene, "composer", "bind_scene")
.args(1)
.rets(1)
.caps(caps::COMPOSER)
.cost(5),
SyscallRegistryEntry::builder(Syscall::ComposerUnbindScene, "composer", "unbind_scene")
.rets(1)
.caps(caps::COMPOSER)
.cost(2),
SyscallRegistryEntry::builder(Syscall::ComposerSetCamera, "composer", "set_camera")
.args(2)
.caps(caps::COMPOSER)
.cost(2),
SyscallRegistryEntry::builder(Syscall::ComposerEmitSprite, "composer", "emit_sprite")
.args(9)
.rets(1)
.caps(caps::COMPOSER)
.cost(5),
];

View File

@ -1,68 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::FsOpen, "fs", "open")
.args(1)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::FsRead, "fs", "read")
.args(1)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::FsWrite, "fs", "write")
.args(2)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::FsClose, "fs", "close").args(1).caps(caps::FS).cost(5),
SyscallRegistryEntry::builder(Syscall::FsListDir, "fs", "list_dir")
.args(1)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::FsExists, "fs", "exists").args(1).rets(1).caps(caps::FS),
SyscallRegistryEntry::builder(Syscall::FsDelete, "fs", "delete")
.args(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::MemSlotCount, "mem", "slot_count")
.rets(2)
.caps(caps::FS),
SyscallRegistryEntry::builder(Syscall::MemSlotStat, "mem", "slot_stat")
.args(1)
.rets(5)
.caps(caps::FS)
.non_deterministic()
.cost(5),
SyscallRegistryEntry::builder(Syscall::MemSlotRead, "mem", "slot_read")
.args(3)
.rets(3)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::MemSlotWrite, "mem", "slot_write")
.args(3)
.rets(2)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::MemSlotCommit, "mem", "slot_commit")
.args(1)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
SyscallRegistryEntry::builder(Syscall::MemSlotClear, "mem", "slot_clear")
.args(1)
.rets(1)
.caps(caps::FS)
.non_deterministic()
.cost(20),
];

View File

@ -1,32 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::GfxClear, "gfx2d", "clear")
.args(1)
.caps(caps::GFX2D)
.cost(20),
SyscallRegistryEntry::builder(Syscall::GfxFillRect, "gfx2d", "fill_rect")
.args(5)
.caps(caps::GFX2D)
.cost(20),
SyscallRegistryEntry::builder(Syscall::GfxDrawLine, "gfx2d", "draw_line")
.args(5)
.caps(caps::GFX2D)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxDrawCircle, "gfx2d", "draw_circle")
.args(4)
.caps(caps::GFX2D)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxDrawDisc, "gfx2d", "draw_disc")
.args(5)
.caps(caps::GFX2D)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxDrawSquare, "gfx2d", "draw_square")
.args(6)
.caps(caps::GFX2D)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxDrawText, "gfx2d", "draw_text")
.args(4)
.caps(caps::GFX2D)
.cost(20),
];

View File

@ -1,32 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::GfxUiClear, "gfxui", "clear")
.args(1)
.caps(caps::GFXUI)
.cost(20),
SyscallRegistryEntry::builder(Syscall::GfxUiFillRect, "gfxui", "fill_rect")
.args(5)
.caps(caps::GFXUI)
.cost(20),
SyscallRegistryEntry::builder(Syscall::GfxUiDrawLine, "gfxui", "draw_line")
.args(5)
.caps(caps::GFXUI)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxUiDrawCircle, "gfxui", "draw_circle")
.args(4)
.caps(caps::GFXUI)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxUiDrawDisc, "gfxui", "draw_disc")
.args(5)
.caps(caps::GFXUI)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxUiDrawSquare, "gfxui", "draw_square")
.args(6)
.caps(caps::GFXUI)
.cost(5),
SyscallRegistryEntry::builder(Syscall::GfxUiDrawText, "gfxui", "draw_text")
.args(4)
.caps(caps::GFXUI)
.cost(20),
];

View File

@ -1,14 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
SyscallRegistryEntry::builder(Syscall::LogWrite, "log", "write")
.args(2)
.caps(caps::LOG)
.non_deterministic()
.cost(5),
SyscallRegistryEntry::builder(Syscall::LogWriteTag, "log", "write_tag")
.args(3)
.caps(caps::LOG)
.non_deterministic()
.cost(5),
];

View File

@ -1,24 +0,0 @@
mod asset;
mod audio;
mod bank;
mod composer;
mod fs;
mod gfx;
mod gfxui;
mod log;
mod system;
use super::SyscallRegistryEntry;
pub(crate) fn all_entries() -> impl Iterator<Item = &'static SyscallRegistryEntry> {
system::ENTRIES
.iter()
.chain(gfx::ENTRIES.iter())
.chain(gfxui::ENTRIES.iter())
.chain(composer::ENTRIES.iter())
.chain(audio::ENTRIES.iter())
.chain(fs::ENTRIES.iter())
.chain(log::ENTRIES.iter())
.chain(asset::ENTRIES.iter())
.chain(bank::ENTRIES.iter())
}

View File

@ -1,6 +0,0 @@
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
pub(crate) const ENTRIES: &[SyscallRegistryEntry] =
&[SyscallRegistryEntry::builder(Syscall::SystemHasCart, "system", "has_cart")
.rets(1)
.caps(caps::SYSTEM)];

View File

@ -1,125 +0,0 @@
use super::{Syscall, SyscallMeta, domains};
pub(crate) fn meta_for(syscall: Syscall) -> &'static SyscallMeta {
for entry in domains::all_entries() {
if entry.syscall == syscall {
return &entry.meta;
}
}
panic!("Missing SyscallMeta for {:?}", syscall);
}
impl Syscall {
pub fn from_u32(id: u32) -> Option<Self> {
match id {
0x0001 => Some(Self::SystemHasCart),
0x1001 => Some(Self::GfxClear),
0x1002 => Some(Self::GfxFillRect),
0x1003 => Some(Self::GfxDrawLine),
0x1004 => Some(Self::GfxDrawCircle),
0x1005 => Some(Self::GfxDrawDisc),
0x1006 => Some(Self::GfxDrawSquare),
0x1008 => Some(Self::GfxDrawText),
0x1201 => Some(Self::GfxUiClear),
0x1202 => Some(Self::GfxUiFillRect),
0x1203 => Some(Self::GfxUiDrawLine),
0x1204 => Some(Self::GfxUiDrawCircle),
0x1205 => Some(Self::GfxUiDrawDisc),
0x1206 => Some(Self::GfxUiDrawSquare),
0x1208 => Some(Self::GfxUiDrawText),
0x1101 => Some(Self::ComposerBindScene),
0x1102 => Some(Self::ComposerUnbindScene),
0x1103 => Some(Self::ComposerSetCamera),
0x1104 => Some(Self::ComposerEmitSprite),
0x3001 => Some(Self::AudioPlaySample),
0x3002 => Some(Self::AudioPlay),
0x4001 => Some(Self::FsOpen),
0x4002 => Some(Self::FsRead),
0x4003 => Some(Self::FsWrite),
0x4004 => Some(Self::FsClose),
0x4005 => Some(Self::FsListDir),
0x4006 => Some(Self::FsExists),
0x4007 => Some(Self::FsDelete),
0x4201 => Some(Self::MemSlotCount),
0x4202 => Some(Self::MemSlotStat),
0x4203 => Some(Self::MemSlotRead),
0x4204 => Some(Self::MemSlotWrite),
0x4205 => Some(Self::MemSlotCommit),
0x4206 => Some(Self::MemSlotClear),
0x5001 => Some(Self::LogWrite),
0x5002 => Some(Self::LogWriteTag),
0x6001 => Some(Self::AssetLoad),
0x6002 => Some(Self::AssetStatus),
0x6003 => Some(Self::AssetCommit),
0x6004 => Some(Self::AssetCancel),
0x6005 => Some(Self::AssetBacklogInfo),
0x6006 => Some(Self::AssetBacklogPosition),
0x6007 => Some(Self::AssetBacklogMove),
0x6008 => Some(Self::AssetBacklogPromote),
0x6009 => Some(Self::AssetBacklogDemote),
0x600A => Some(Self::AssetTargetStatus),
0x6101 => Some(Self::BankInfo),
_ => None,
}
}
pub fn args_count(&self) -> usize {
super::meta_for(*self).arg_slots as usize
}
pub fn results_count(&self) -> usize {
super::meta_for(*self).ret_slots as usize
}
pub fn name(&self) -> &'static str {
match self {
Self::SystemHasCart => "SystemHasCart",
Self::GfxClear => "GfxClear",
Self::GfxFillRect => "GfxFillRect",
Self::GfxDrawLine => "GfxDrawLine",
Self::GfxDrawCircle => "GfxDrawCircle",
Self::GfxDrawDisc => "GfxDrawDisc",
Self::GfxDrawSquare => "GfxDrawSquare",
Self::GfxDrawText => "GfxDrawText",
Self::GfxUiClear => "GfxUiClear",
Self::GfxUiFillRect => "GfxUiFillRect",
Self::GfxUiDrawLine => "GfxUiDrawLine",
Self::GfxUiDrawCircle => "GfxUiDrawCircle",
Self::GfxUiDrawDisc => "GfxUiDrawDisc",
Self::GfxUiDrawSquare => "GfxUiDrawSquare",
Self::GfxUiDrawText => "GfxUiDrawText",
Self::ComposerBindScene => "ComposerBindScene",
Self::ComposerUnbindScene => "ComposerUnbindScene",
Self::ComposerSetCamera => "ComposerSetCamera",
Self::ComposerEmitSprite => "ComposerEmitSprite",
Self::AudioPlaySample => "AudioPlaySample",
Self::AudioPlay => "AudioPlay",
Self::FsOpen => "FsOpen",
Self::FsRead => "FsRead",
Self::FsWrite => "FsWrite",
Self::FsClose => "FsClose",
Self::FsListDir => "FsListDir",
Self::FsExists => "FsExists",
Self::FsDelete => "FsDelete",
Self::MemSlotCount => "MemSlotCount",
Self::MemSlotStat => "MemSlotStat",
Self::MemSlotRead => "MemSlotRead",
Self::MemSlotWrite => "MemSlotWrite",
Self::MemSlotCommit => "MemSlotCommit",
Self::MemSlotClear => "MemSlotClear",
Self::LogWrite => "LogWrite",
Self::LogWriteTag => "LogWriteTag",
Self::AssetLoad => "AssetLoad",
Self::AssetStatus => "AssetStatus",
Self::AssetCommit => "AssetCommit",
Self::AssetCancel => "AssetCancel",
Self::AssetBacklogInfo => "AssetBacklogInfo",
Self::AssetBacklogPosition => "AssetBacklogPosition",
Self::AssetBacklogMove => "AssetBacklogMove",
Self::AssetBacklogPromote => "AssetBacklogPromote",
Self::AssetBacklogDemote => "AssetBacklogDemote",
Self::AssetTargetStatus => "AssetTargetStatus",
Self::BankInfo => "BankInfo",
}
}
}

View File

@ -1,156 +0,0 @@
use super::{CapFlags, SyscallMeta, domains};
/// Canonical identity triple for a syscall.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyscallIdentity {
pub module: &'static str,
pub name: &'static str,
pub version: u16,
}
impl SyscallIdentity {
pub fn key(&self) -> (&'static str, &'static str, u16) {
(self.module, self.name, self.version)
}
}
/// Resolved syscall information provided to the loader/VM.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SyscallResolved {
pub id: u32,
pub meta: SyscallMeta,
}
/// Load-time error for syscall resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadError {
UnknownSyscall {
module: &'static str,
name: &'static str,
version: u16,
},
MissingCapability {
required: CapFlags,
provided: CapFlags,
module: &'static str,
name: &'static str,
version: u16,
},
}
/// Load-time error for PBX-declared syscall resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeclaredLoadError {
UnknownSyscall {
module: String,
name: String,
version: u16,
},
MissingCapability {
required: CapFlags,
provided: CapFlags,
module: String,
name: String,
version: u16,
},
AbiMismatch {
module: String,
name: String,
version: u16,
declared_arg_slots: u16,
declared_ret_slots: u16,
expected_arg_slots: u16,
expected_ret_slots: u16,
},
}
fn resolve_syscall_impl(module: &str, name: &str, version: u16) -> Option<SyscallResolved> {
for entry in domains::all_entries() {
if entry.meta.module == module && entry.meta.name == name && entry.meta.version == version {
return Some(SyscallResolved { id: entry.meta.id, meta: entry.meta });
}
}
None
}
pub fn resolve_syscall(
module: &'static str,
name: &'static str,
version: u16,
) -> Option<SyscallResolved> {
resolve_syscall_impl(module, name, version)
}
pub fn resolve_program_syscalls(
declared: &[SyscallIdentity],
caps: CapFlags,
) -> Result<Vec<SyscallResolved>, LoadError> {
let mut out = Vec::with_capacity(declared.len());
for ident in declared {
let Some(res) = resolve_syscall(ident.module, ident.name, ident.version) else {
return Err(LoadError::UnknownSyscall {
module: ident.module,
name: ident.name,
version: ident.version,
});
};
let missing = res.meta.caps & !caps;
if missing != 0 {
return Err(LoadError::MissingCapability {
required: res.meta.caps,
provided: caps,
module: ident.module,
name: ident.name,
version: ident.version,
});
}
out.push(res);
}
Ok(out)
}
pub fn resolve_declared_program_syscalls(
declared: &[prometeu_bytecode::SyscallDecl],
caps: CapFlags,
) -> Result<Vec<SyscallResolved>, DeclaredLoadError> {
let mut out = Vec::with_capacity(declared.len());
for decl in declared {
let Some(res) = resolve_syscall_impl(&decl.module, &decl.name, decl.version) else {
return Err(DeclaredLoadError::UnknownSyscall {
module: decl.module.clone(),
name: decl.name.clone(),
version: decl.version,
});
};
let missing = res.meta.caps & !caps;
if missing != 0 {
return Err(DeclaredLoadError::MissingCapability {
required: res.meta.caps,
provided: caps,
module: decl.module.clone(),
name: decl.name.clone(),
version: decl.version,
});
}
let expected_arg_slots = u16::from(res.meta.arg_slots);
let expected_ret_slots = res.meta.ret_slots;
if decl.arg_slots != expected_arg_slots || decl.ret_slots != expected_ret_slots {
return Err(DeclaredLoadError::AbiMismatch {
module: decl.module.clone(),
name: decl.name.clone(),
version: decl.version,
declared_arg_slots: decl.arg_slots,
declared_ret_slots: decl.ret_slots,
expected_arg_slots,
expected_ret_slots,
});
}
out.push(res);
}
Ok(out)
}

View File

@ -1,488 +0,0 @@
use super::*;
fn all_syscalls() -> Vec<Syscall> {
domains::all_entries().map(|entry| entry.syscall).collect()
}
#[test]
fn every_syscall_has_metadata() {
for sc in all_syscalls() {
let m = meta_for(sc);
assert_eq!(m.id, sc as u32, "id mismatch for {:?}", sc);
assert!(!m.module.is_empty(), "module must be non-empty for id=0x{:08X}", m.id);
assert!(!m.name.is_empty(), "name must be non-empty for id=0x{:08X}", m.id);
assert!(m.version > 0, "version must be > 0 for id=0x{:08X}", m.id);
}
use std::collections::HashSet;
let mut ids = HashSet::new();
let mut identities = HashSet::new();
let mut count = 0usize;
for entry in domains::all_entries() {
count += 1;
assert!(ids.insert(entry.meta.id), "duplicate syscall id 0x{:08X}", entry.meta.id);
let parsed = Syscall::from_u32(entry.meta.id).expect("id not recognized by enum mapping");
assert_eq!(parsed as u32, entry.meta.id);
let key = (entry.meta.module, entry.meta.name, entry.meta.version);
assert!(
identities.insert(key),
"duplicate canonical identity: ({}.{}, v{})",
entry.meta.module,
entry.meta.name,
entry.meta.version
);
}
assert_eq!(count, all_syscalls().len());
}
#[test]
fn resolver_returns_expected_id_for_known_identity() {
let id = resolve_syscall("gfx2d", "clear", 1).expect("known identity must resolve");
assert_eq!(id.id, 0x1001);
assert_eq!(id.meta.module, "gfx2d");
assert_eq!(id.meta.name, "clear");
assert_eq!(id.meta.version, 1);
}
#[test]
fn resolver_rejects_unknown_identity() {
let res = resolve_syscall("gfx2d", "nonexistent", 1);
assert!(res.is_none());
let requested = [SyscallIdentity { module: "gfx2d", name: "nonexistent", version: 1 }];
let err = resolve_program_syscalls(&requested, 0).unwrap_err();
match err {
LoadError::UnknownSyscall { module, name, version } => {
assert_eq!(module, "gfx2d");
assert_eq!(name, "nonexistent");
assert_eq!(version, 1);
}
_ => panic!("expected UnknownSyscall error"),
}
}
#[test]
fn resolver_rejects_removed_legacy_gfx_set_sprite_identity() {
assert!(resolve_syscall("gfx2d", "set_sprite", 1).is_none());
let requested = [SyscallIdentity { module: "gfx2d", name: "set_sprite", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(err, LoadError::UnknownSyscall { module: "gfx2d", name: "set_sprite", version: 1 });
}
#[test]
fn resolver_rejects_userland_system_run_cart_identity() {
assert!(Syscall::from_u32(0x0002).is_none());
assert!(resolve_syscall("system", "run_cart", 1).is_none());
let requested = [SyscallIdentity { module: "system", name: "run_cart", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(err, LoadError::UnknownSyscall { module: "system", name: "run_cart", version: 1 });
let declared = [prometeu_bytecode::SyscallDecl {
module: "system".into(),
name: "run_cart".into(),
version: 1,
arg_slots: 0,
ret_slots: 0,
}];
let err = resolve_declared_program_syscalls(&declared, caps::ALL).unwrap_err();
assert_eq!(
err,
DeclaredLoadError::UnknownSyscall {
module: "system".into(),
name: "run_cart".into(),
version: 1,
}
);
}
#[test]
fn resolver_enforces_capabilities() {
let requested = [SyscallIdentity { module: "gfx2d", name: "clear", version: 1 }];
let err = resolve_program_syscalls(&requested, 0).unwrap_err();
match err {
LoadError::MissingCapability { required, provided, module, name, version } => {
assert_eq!(module, "gfx2d");
assert_eq!(name, "clear");
assert_eq!(version, 1);
assert_ne!(required, 0);
assert_eq!(provided, 0);
}
_ => panic!("expected MissingCapability error"),
}
let ok = resolve_program_syscalls(&requested, caps::GFX2D).expect("must resolve with caps");
assert_eq!(ok.len(), 1);
assert_eq!(ok[0].id, 0x1001);
}
#[test]
fn resolver_exposes_split_render_capabilities() {
let composer = resolve_syscall("composer", "bind_scene", 1).expect("composer resolves");
assert_eq!(composer.meta.caps, caps::COMPOSER);
let gfx2d = resolve_syscall("gfx2d", "clear", 1).expect("gfx2d resolves");
assert_eq!(gfx2d.meta.caps, caps::GFX2D);
let gfxui = resolve_syscall("gfxui", "clear", 1).expect("gfxui resolves");
assert_eq!(gfxui.id, 0x1201);
assert_eq!(gfxui.meta.caps, caps::GFXUI);
assert!(resolve_syscall("gfx", "clear", 1).is_none());
}
#[test]
fn declared_resolver_returns_expected_id_for_known_identity() {
let declared = [prometeu_bytecode::SyscallDecl {
module: "gfx2d".into(),
name: "clear".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
}];
let ok =
resolve_declared_program_syscalls(&declared, caps::GFX2D).expect("must resolve with ABI");
assert_eq!(ok.len(), 1);
assert_eq!(ok[0].id, 0x1001);
}
#[test]
fn declared_resolver_rejects_unknown_identity() {
let declared = [prometeu_bytecode::SyscallDecl {
module: "gfx2d".into(),
name: "nonexistent".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
}];
let err = resolve_declared_program_syscalls(&declared, caps::GFX2D).unwrap_err();
assert_eq!(
err,
DeclaredLoadError::UnknownSyscall {
module: "gfx2d".into(),
name: "nonexistent".into(),
version: 1,
}
);
}
#[test]
fn declared_resolver_rejects_missing_capability() {
let declared = [prometeu_bytecode::SyscallDecl {
module: "gfx2d".into(),
name: "clear".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
}];
let err = resolve_declared_program_syscalls(&declared, caps::NONE).unwrap_err();
assert_eq!(
err,
DeclaredLoadError::MissingCapability {
required: caps::GFX2D,
provided: caps::NONE,
module: "gfx2d".into(),
name: "clear".into(),
version: 1,
}
);
}
#[test]
fn declared_resolver_rejects_abi_mismatch() {
let declared = [prometeu_bytecode::SyscallDecl {
module: "gfx2d".into(),
name: "draw_line".into(),
version: 1,
arg_slots: 4,
ret_slots: 0,
}];
let err = resolve_declared_program_syscalls(&declared, caps::GFX2D).unwrap_err();
assert_eq!(
err,
DeclaredLoadError::AbiMismatch {
module: "gfx2d".into(),
name: "draw_line".into(),
version: 1,
declared_arg_slots: 4,
declared_ret_slots: 0,
expected_arg_slots: 5,
expected_ret_slots: 0,
}
);
}
#[test]
fn status_first_syscall_signatures_are_pinned() {
let clear = meta_for(Syscall::GfxClear);
assert_eq!(clear.arg_slots, 1);
assert_eq!(clear.ret_slots, 0);
let fill_rect = meta_for(Syscall::GfxFillRect);
assert_eq!(fill_rect.arg_slots, 5);
assert_eq!(fill_rect.ret_slots, 0);
let draw_line = meta_for(Syscall::GfxDrawLine);
assert_eq!(draw_line.arg_slots, 5);
assert_eq!(draw_line.ret_slots, 0);
let draw_circle = meta_for(Syscall::GfxDrawCircle);
assert_eq!(draw_circle.arg_slots, 4);
assert_eq!(draw_circle.ret_slots, 0);
let draw_disc = meta_for(Syscall::GfxDrawDisc);
assert_eq!(draw_disc.arg_slots, 5);
assert_eq!(draw_disc.ret_slots, 0);
let draw_square = meta_for(Syscall::GfxDrawSquare);
assert_eq!(draw_square.arg_slots, 6);
assert_eq!(draw_square.ret_slots, 0);
let draw_text = meta_for(Syscall::GfxDrawText);
assert_eq!(draw_text.arg_slots, 4);
assert_eq!(draw_text.ret_slots, 0);
let bind_scene = meta_for(Syscall::ComposerBindScene);
assert_eq!(bind_scene.arg_slots, 1);
assert_eq!(bind_scene.ret_slots, 1);
let unbind_scene = meta_for(Syscall::ComposerUnbindScene);
assert_eq!(unbind_scene.arg_slots, 0);
assert_eq!(unbind_scene.ret_slots, 1);
let set_camera = meta_for(Syscall::ComposerSetCamera);
assert_eq!(set_camera.arg_slots, 2);
assert_eq!(set_camera.ret_slots, 0);
let emit_sprite = meta_for(Syscall::ComposerEmitSprite);
assert_eq!(emit_sprite.arg_slots, 9);
assert_eq!(emit_sprite.ret_slots, 1);
let audio_play_sample = meta_for(Syscall::AudioPlaySample);
assert_eq!(audio_play_sample.arg_slots, 5);
assert_eq!(audio_play_sample.ret_slots, 1);
let audio_play = meta_for(Syscall::AudioPlay);
assert_eq!(audio_play.arg_slots, 7);
assert_eq!(audio_play.ret_slots, 1);
let asset_load = meta_for(Syscall::AssetLoad);
assert_eq!(asset_load.arg_slots, 2);
assert_eq!(asset_load.ret_slots, 2);
let asset_commit = meta_for(Syscall::AssetCommit);
assert_eq!(asset_commit.arg_slots, 1);
assert_eq!(asset_commit.ret_slots, 1);
let asset_cancel = meta_for(Syscall::AssetCancel);
assert_eq!(asset_cancel.arg_slots, 1);
assert_eq!(asset_cancel.ret_slots, 1);
let asset_backlog_info = meta_for(Syscall::AssetBacklogInfo);
assert_eq!(asset_backlog_info.arg_slots, 0);
assert_eq!(asset_backlog_info.ret_slots, 7);
let asset_backlog_position = meta_for(Syscall::AssetBacklogPosition);
assert_eq!(asset_backlog_position.arg_slots, 1);
assert_eq!(asset_backlog_position.ret_slots, 4);
let asset_backlog_move = meta_for(Syscall::AssetBacklogMove);
assert_eq!(asset_backlog_move.arg_slots, 2);
assert_eq!(asset_backlog_move.ret_slots, 1);
let asset_backlog_promote = meta_for(Syscall::AssetBacklogPromote);
assert_eq!(asset_backlog_promote.arg_slots, 1);
assert_eq!(asset_backlog_promote.ret_slots, 1);
let asset_backlog_demote = meta_for(Syscall::AssetBacklogDemote);
assert_eq!(asset_backlog_demote.arg_slots, 1);
assert_eq!(asset_backlog_demote.ret_slots, 1);
let asset_target_status = meta_for(Syscall::AssetTargetStatus);
assert_eq!(asset_target_status.arg_slots, 2);
assert_eq!(asset_target_status.ret_slots, 6);
let bank_info = meta_for(Syscall::BankInfo);
assert_eq!(bank_info.arg_slots, 1);
assert_eq!(bank_info.ret_slots, 2);
}
#[test]
fn resolver_rejects_removed_bank_slot_info_identity() {
assert!(resolve_syscall("bank", "slot_info", 1).is_none());
let requested = [SyscallIdentity { module: "bank", name: "slot_info", version: 1 }];
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
assert_eq!(err, LoadError::UnknownSyscall { module: "bank", name: "slot_info", version: 1 });
}
#[test]
fn declared_resolver_rejects_legacy_status_first_signatures() {
let declared = vec![
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
},
prometeu_bytecode::SyscallDecl {
module: "audio".into(),
name: "play_sample".into(),
version: 1,
arg_slots: 5,
ret_slots: 0,
},
prometeu_bytecode::SyscallDecl {
module: "audio".into(),
name: "play".into(),
version: 1,
arg_slots: 7,
ret_slots: 0,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
ret_slots: 2,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
name: "commit".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
name: "cancel".into(),
version: 1,
arg_slots: 1,
ret_slots: 0,
},
];
for decl in declared {
let err = resolve_declared_program_syscalls(std::slice::from_ref(&decl), caps::ALL)
.expect_err("legacy signature must be rejected");
match err {
DeclaredLoadError::AbiMismatch {
module,
name,
version,
declared_arg_slots,
declared_ret_slots,
expected_arg_slots,
expected_ret_slots,
} => {
assert_eq!(module, decl.module);
assert_eq!(name, decl.name);
assert_eq!(version, decl.version);
assert_eq!(declared_arg_slots, decl.arg_slots);
assert_eq!(declared_ret_slots, decl.ret_slots);
assert!(
expected_arg_slots != declared_arg_slots
|| expected_ret_slots != declared_ret_slots
);
}
other => panic!("expected AbiMismatch, got {:?}", other),
}
}
}
#[test]
fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() {
let declared = vec![
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "bind_scene".into(),
version: 1,
arg_slots: 1,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "unbind_scene".into(),
version: 1,
arg_slots: 0,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "composer".into(),
name: "emit_sprite".into(),
version: 1,
arg_slots: 9,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "audio".into(),
name: "play".into(),
version: 1,
arg_slots: 7,
ret_slots: 1,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 2,
ret_slots: 2,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
name: "commit".into(),
version: 1,
arg_slots: 1,
ret_slots: 1,
},
];
let resolved =
resolve_declared_program_syscalls(&declared, caps::COMPOSER | caps::AUDIO | caps::ASSET)
.expect("mixed status-first surface must resolve together");
assert_eq!(resolved.len(), declared.len());
assert_eq!(resolved[0].meta.ret_slots, 1);
assert_eq!(resolved[1].meta.ret_slots, 1);
assert_eq!(resolved[2].meta.ret_slots, 1);
assert_eq!(resolved[3].meta.ret_slots, 1);
assert_eq!(resolved[4].meta.ret_slots, 2);
assert_eq!(resolved[5].meta.ret_slots, 1);
}
#[test]
fn memcard_syscall_signatures_are_pinned() {
let slot_count = meta_for(Syscall::MemSlotCount);
assert_eq!(slot_count.module, "mem");
assert_eq!(slot_count.name, "slot_count");
assert_eq!(slot_count.arg_slots, 0);
assert_eq!(slot_count.ret_slots, 2);
let slot_stat = meta_for(Syscall::MemSlotStat);
assert_eq!(slot_stat.arg_slots, 1);
assert_eq!(slot_stat.ret_slots, 5);
let slot_read = meta_for(Syscall::MemSlotRead);
assert_eq!(slot_read.arg_slots, 3);
assert_eq!(slot_read.ret_slots, 3);
let slot_write = meta_for(Syscall::MemSlotWrite);
assert_eq!(slot_write.arg_slots, 3);
assert_eq!(slot_write.ret_slots, 2);
let slot_commit = meta_for(Syscall::MemSlotCommit);
assert_eq!(slot_commit.arg_slots, 1);
assert_eq!(slot_commit.ret_slots, 1);
let slot_clear = meta_for(Syscall::MemSlotClear);
assert_eq!(slot_clear.arg_slots, 1);
assert_eq!(slot_clear.ret_slots, 1);
}

View File

@ -1,497 +0,0 @@
use crate::log::{LogLevel, LogService, LogSource};
use crate::render_worker::RenderWorkerTelemetry;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
#[derive(Debug, Clone, Copy, Default)]
pub struct TelemetryFrame {
pub frame_index: u64,
pub vm_steps: u32,
pub cycles_used: u64,
pub cycles_budget: u64,
pub syscalls: u32,
pub host_cpu_time_us: u64,
pub completed_logical_frames: u32,
pub violations: u32,
// Bank telemetry
pub glyph_slots_used: u32,
pub glyph_slots_total: u32,
pub sound_slots_used: u32,
pub sound_slots_total: u32,
pub scene_slots_used: u32,
pub scene_slots_total: u32,
// RAM (Heap)
pub heap_used_bytes: usize,
pub heap_max_bytes: usize,
pub vm_heap_allocations: u64,
pub vm_string_materializations: u64,
// Log Pressure from the last completed logical frame
pub logs_count: u32,
// Render handoff/presentation telemetry
pub produced_submissions: u64,
pub replaced_before_consume: u64,
pub consumed_submissions: u64,
pub published_frames: u64,
pub presented_frames: u64,
pub repeated_presents: u64,
pub render_errors: u64,
pub publish_errors: u64,
pub present_errors: u64,
pub stale_epoch_discards: u64,
pub shutdown_discards: u64,
pub shutdown_timeouts: u64,
pub worker_panics: u64,
pub last_produced_frame_id: u64,
pub last_consumed_frame_id: u64,
pub last_published_frame_id: u64,
pub last_presented_frame_id: u64,
pub last_dropped_frame_id: u64,
pub last_error_frame_id: u64,
pub active_render_epoch: u64,
}
impl TelemetryFrame {
pub fn render_worker_telemetry(&self) -> RenderWorkerTelemetry {
RenderWorkerTelemetry {
produced_submissions: self.produced_submissions,
replaced_before_consume: self.replaced_before_consume,
consumed_submissions: self.consumed_submissions,
published_frames: self.published_frames,
presented_frames: self.presented_frames,
repeated_frames: self.repeated_presents,
stale_submission_discards: self.stale_epoch_discards,
render_failures: self.render_errors,
publish_failures: self.publish_errors,
present_failures: self.present_errors,
shutdown_timeouts: self.shutdown_timeouts,
worker_panics: self.worker_panics,
last_produced_frame_id: self.last_produced_frame_id,
last_consumed_frame_id: self.last_consumed_frame_id,
last_published_frame_id: self.last_published_frame_id,
last_presented_frame_id: self.last_presented_frame_id,
last_dropped_frame_id: self.last_dropped_frame_id,
last_error_frame_id: self.last_error_frame_id,
active_render_epoch: self.active_render_epoch,
}
}
}
/// Thread-safe, atomic telemetry storage for real-time monitoring by the host.
/// This follows the push-based model from DEC-0005 to avoid expensive scans or locks.
#[derive(Debug, Default)]
pub struct AtomicTelemetry {
pub frame_index: AtomicU64,
pub cycles_used: AtomicU64,
pub cycles_budget: AtomicU64,
pub syscalls: AtomicU32,
pub host_cpu_time_us: AtomicU64,
pub vm_steps: AtomicU32,
pub completed_logical_frames: AtomicU32,
pub violations: AtomicU32,
// Bank telemetry
pub glyph_slots_used: AtomicU32,
pub glyph_slots_total: AtomicU32,
pub sound_slots_used: AtomicU32,
pub sound_slots_total: AtomicU32,
pub scene_slots_used: AtomicU32,
pub scene_slots_total: AtomicU32,
// RAM (Heap)
pub heap_used_bytes: AtomicUsize,
pub heap_max_bytes: AtomicUsize,
pub vm_heap_allocations: AtomicU64,
pub vm_string_materializations: AtomicU64,
// Transient in-flight log counter for the current logical frame
pub current_logs_count: Arc<AtomicU32>,
// Persisted log count from the last completed logical frame
pub logs_count: AtomicU32,
// Render handoff/presentation telemetry
pub produced_submissions: AtomicU64,
pub replaced_before_consume: AtomicU64,
pub consumed_submissions: AtomicU64,
pub published_frames: AtomicU64,
pub presented_frames: AtomicU64,
pub repeated_presents: AtomicU64,
pub render_errors: AtomicU64,
pub publish_errors: AtomicU64,
pub present_errors: AtomicU64,
pub stale_epoch_discards: AtomicU64,
pub shutdown_discards: AtomicU64,
pub shutdown_timeouts: AtomicU64,
pub worker_panics: AtomicU64,
pub last_produced_frame_id: AtomicU64,
pub last_consumed_frame_id: AtomicU64,
pub last_published_frame_id: AtomicU64,
pub last_presented_frame_id: AtomicU64,
pub last_dropped_frame_id: AtomicU64,
pub last_error_frame_id: AtomicU64,
pub active_render_epoch: AtomicU64,
}
impl AtomicTelemetry {
pub fn new(current_logs_count: Arc<AtomicU32>) -> Self {
Self { current_logs_count, ..Default::default() }
}
/// Snapshots the current atomic state into a TelemetryFrame.
pub fn snapshot(&self) -> TelemetryFrame {
TelemetryFrame {
frame_index: self.frame_index.load(Ordering::Relaxed),
cycles_used: self.cycles_used.load(Ordering::Relaxed),
cycles_budget: self.cycles_budget.load(Ordering::Relaxed),
syscalls: self.syscalls.load(Ordering::Relaxed),
host_cpu_time_us: self.host_cpu_time_us.load(Ordering::Relaxed),
completed_logical_frames: self.completed_logical_frames.load(Ordering::Relaxed),
violations: self.violations.load(Ordering::Relaxed),
glyph_slots_used: self.glyph_slots_used.load(Ordering::Relaxed),
glyph_slots_total: self.glyph_slots_total.load(Ordering::Relaxed),
sound_slots_used: self.sound_slots_used.load(Ordering::Relaxed),
sound_slots_total: self.sound_slots_total.load(Ordering::Relaxed),
scene_slots_used: self.scene_slots_used.load(Ordering::Relaxed),
scene_slots_total: self.scene_slots_total.load(Ordering::Relaxed),
heap_used_bytes: self.heap_used_bytes.load(Ordering::Relaxed),
heap_max_bytes: self.heap_max_bytes.load(Ordering::Relaxed),
vm_heap_allocations: self.vm_heap_allocations.load(Ordering::Relaxed),
vm_string_materializations: self.vm_string_materializations.load(Ordering::Relaxed),
logs_count: self.logs_count.load(Ordering::Relaxed),
vm_steps: self.vm_steps.load(Ordering::Relaxed),
produced_submissions: self.produced_submissions.load(Ordering::Relaxed),
replaced_before_consume: self.replaced_before_consume.load(Ordering::Relaxed),
consumed_submissions: self.consumed_submissions.load(Ordering::Relaxed),
published_frames: self.published_frames.load(Ordering::Relaxed),
presented_frames: self.presented_frames.load(Ordering::Relaxed),
repeated_presents: self.repeated_presents.load(Ordering::Relaxed),
render_errors: self.render_errors.load(Ordering::Relaxed),
publish_errors: self.publish_errors.load(Ordering::Relaxed),
present_errors: self.present_errors.load(Ordering::Relaxed),
stale_epoch_discards: self.stale_epoch_discards.load(Ordering::Relaxed),
shutdown_discards: self.shutdown_discards.load(Ordering::Relaxed),
shutdown_timeouts: self.shutdown_timeouts.load(Ordering::Relaxed),
worker_panics: self.worker_panics.load(Ordering::Relaxed),
last_produced_frame_id: self.last_produced_frame_id.load(Ordering::Relaxed),
last_consumed_frame_id: self.last_consumed_frame_id.load(Ordering::Relaxed),
last_published_frame_id: self.last_published_frame_id.load(Ordering::Relaxed),
last_presented_frame_id: self.last_presented_frame_id.load(Ordering::Relaxed),
last_dropped_frame_id: self.last_dropped_frame_id.load(Ordering::Relaxed),
last_error_frame_id: self.last_error_frame_id.load(Ordering::Relaxed),
active_render_epoch: self.active_render_epoch.load(Ordering::Relaxed),
}
}
pub fn reset(&self) {
self.frame_index.store(0, Ordering::Relaxed);
self.cycles_used.store(0, Ordering::Relaxed);
self.syscalls.store(0, Ordering::Relaxed);
self.host_cpu_time_us.store(0, Ordering::Relaxed);
self.completed_logical_frames.store(0, Ordering::Relaxed);
self.violations.store(0, Ordering::Relaxed);
self.glyph_slots_used.store(0, Ordering::Relaxed);
self.glyph_slots_total.store(0, Ordering::Relaxed);
self.sound_slots_used.store(0, Ordering::Relaxed);
self.sound_slots_total.store(0, Ordering::Relaxed);
self.scene_slots_used.store(0, Ordering::Relaxed);
self.scene_slots_total.store(0, Ordering::Relaxed);
self.heap_used_bytes.store(0, Ordering::Relaxed);
self.vm_heap_allocations.store(0, Ordering::Relaxed);
self.vm_string_materializations.store(0, Ordering::Relaxed);
self.vm_steps.store(0, Ordering::Relaxed);
self.logs_count.store(0, Ordering::Relaxed);
self.current_logs_count.store(0, Ordering::Relaxed);
self.produced_submissions.store(0, Ordering::Relaxed);
self.replaced_before_consume.store(0, Ordering::Relaxed);
self.consumed_submissions.store(0, Ordering::Relaxed);
self.published_frames.store(0, Ordering::Relaxed);
self.presented_frames.store(0, Ordering::Relaxed);
self.repeated_presents.store(0, Ordering::Relaxed);
self.render_errors.store(0, Ordering::Relaxed);
self.publish_errors.store(0, Ordering::Relaxed);
self.present_errors.store(0, Ordering::Relaxed);
self.stale_epoch_discards.store(0, Ordering::Relaxed);
self.shutdown_discards.store(0, Ordering::Relaxed);
self.shutdown_timeouts.store(0, Ordering::Relaxed);
self.worker_panics.store(0, Ordering::Relaxed);
self.last_produced_frame_id.store(0, Ordering::Relaxed);
self.last_consumed_frame_id.store(0, Ordering::Relaxed);
self.last_published_frame_id.store(0, Ordering::Relaxed);
self.last_presented_frame_id.store(0, Ordering::Relaxed);
self.last_dropped_frame_id.store(0, Ordering::Relaxed);
self.last_error_frame_id.store(0, Ordering::Relaxed);
self.active_render_epoch.store(0, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CertificationConfig {
pub enabled: bool,
pub cycles_budget_per_frame: Option<u64>,
pub max_syscalls_per_frame: Option<u32>,
pub max_host_cpu_us_per_frame: Option<u64>,
pub max_glyph_slots_used: Option<u32>,
pub max_sound_slots_used: Option<u32>,
pub max_heap_bytes: Option<usize>,
pub max_logs_per_frame: Option<u32>,
}
pub struct Certifier {
pub config: CertificationConfig,
}
impl Certifier {
pub fn new(config: CertificationConfig) -> Self {
Self { config }
}
pub fn evaluate(
&self,
telemetry: &TelemetryFrame,
log_service: &mut LogService,
ts_ms: u64,
) -> usize {
if !self.config.enabled {
return 0;
}
let mut violations = 0;
// 1. Cycles
if let Some(budget) = self.config.cycles_budget_per_frame
&& telemetry.cycles_used > budget
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA01,
format!(
"Cert: cycles_used exceeded budget ({} > {})",
telemetry.cycles_used, budget
),
);
violations += 1;
}
// 2. Syscalls
if let Some(limit) = self.config.max_syscalls_per_frame
&& telemetry.syscalls > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA02,
format!(
"Cert: syscalls per frame exceeded limit ({} > {})",
telemetry.syscalls, limit
),
);
violations += 1;
}
// 3. CPU Time
if let Some(limit) = self.config.max_host_cpu_us_per_frame
&& telemetry.host_cpu_time_us > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA03,
format!(
"Cert: host_cpu_time_us exceeded limit ({} > {})",
telemetry.host_cpu_time_us, limit
),
);
violations += 1;
}
// 4. GLYPH bank slots
if let Some(limit) = self.config.max_glyph_slots_used
&& telemetry.glyph_slots_used > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA04,
format!(
"Cert: GLYPH bank exceeded slot limit ({} > {})",
telemetry.glyph_slots_used, limit
),
);
violations += 1;
}
// 5. SOUNDS bank slots
if let Some(limit) = self.config.max_sound_slots_used
&& telemetry.sound_slots_used > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA05,
format!(
"Cert: SOUNDS bank exceeded slot limit ({} > {})",
telemetry.sound_slots_used, limit
),
);
violations += 1;
}
// 6. Heap Memory
if let Some(limit) = self.config.max_heap_bytes
&& telemetry.heap_used_bytes > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA06,
format!(
"Cert: Heap memory exceeded limit ({} > {})",
telemetry.heap_used_bytes, limit
),
);
violations += 1;
}
// 7. Log Pressure
if let Some(limit) = self.config.max_logs_per_frame
&& telemetry.logs_count > limit
{
log_service.log(
ts_ms,
telemetry.frame_index,
LogLevel::Warn,
LogSource::Pos,
0xCA07,
format!("Cert: Log pressure exceeded limit ({} > {})", telemetry.logs_count, limit),
);
violations += 1;
}
violations
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::LogService;
#[test]
fn test_certifier_violations() {
let mut ls = LogService::new(10);
let config = CertificationConfig {
enabled: true,
cycles_budget_per_frame: Some(100),
max_syscalls_per_frame: Some(5),
max_host_cpu_us_per_frame: Some(1000),
max_glyph_slots_used: Some(1),
..Default::default()
};
let cert = Certifier::new(config);
let tel = TelemetryFrame {
cycles_used: 150,
syscalls: 10,
host_cpu_time_us: 500,
glyph_slots_used: 2,
..Default::default()
};
let violations = cert.evaluate(&tel, &mut ls, 1000);
assert_eq!(violations, 3);
let logs = ls.get_recent(10);
assert_eq!(logs.len(), 3);
assert!(logs[0].msg.contains("cycles_used"));
assert!(logs[1].msg.contains("syscalls"));
assert!(logs[2].msg.contains("GLYPH bank"));
}
#[test]
fn snapshot_uses_persisted_last_frame_logs() {
let current = Arc::new(AtomicU32::new(7));
let tel = AtomicTelemetry::new(Arc::clone(&current));
tel.logs_count.store(3, Ordering::Relaxed);
let snapshot = tel.snapshot();
assert_eq!(snapshot.logs_count, 3);
assert_eq!(current.load(Ordering::Relaxed), 7);
}
#[test]
fn snapshot_includes_internal_allocation_evidence() {
let current = Arc::new(AtomicU32::new(0));
let tel = AtomicTelemetry::new(current);
tel.vm_heap_allocations.store(2, Ordering::Relaxed);
tel.vm_string_materializations.store(5, Ordering::Relaxed);
let snapshot = tel.snapshot();
assert_eq!(snapshot.vm_heap_allocations, 2);
assert_eq!(snapshot.vm_string_materializations, 5);
}
#[test]
fn snapshot_maps_render_worker_telemetry() {
let current = Arc::new(AtomicU32::new(0));
let tel = AtomicTelemetry::new(current);
tel.produced_submissions.store(2, Ordering::Relaxed);
tel.replaced_before_consume.store(1, Ordering::Relaxed);
tel.consumed_submissions.store(1, Ordering::Relaxed);
tel.published_frames.store(1, Ordering::Relaxed);
tel.presented_frames.store(1, Ordering::Relaxed);
tel.repeated_presents.store(3, Ordering::Relaxed);
tel.render_errors.store(4, Ordering::Relaxed);
tel.publish_errors.store(5, Ordering::Relaxed);
tel.present_errors.store(6, Ordering::Relaxed);
tel.stale_epoch_discards.store(7, Ordering::Relaxed);
tel.shutdown_timeouts.store(8, Ordering::Relaxed);
tel.worker_panics.store(9, Ordering::Relaxed);
tel.last_produced_frame_id.store(10, Ordering::Relaxed);
tel.last_consumed_frame_id.store(11, Ordering::Relaxed);
tel.last_published_frame_id.store(12, Ordering::Relaxed);
tel.last_presented_frame_id.store(13, Ordering::Relaxed);
tel.last_dropped_frame_id.store(14, Ordering::Relaxed);
tel.last_error_frame_id.store(15, Ordering::Relaxed);
tel.active_render_epoch.store(16, Ordering::Relaxed);
let worker = tel.snapshot().render_worker_telemetry();
assert_eq!(worker.produced_submissions, 2);
assert_eq!(worker.replaced_before_consume, 1);
assert_eq!(worker.consumed_submissions, 1);
assert_eq!(worker.published_frames, 1);
assert_eq!(worker.presented_frames, 1);
assert_eq!(worker.repeated_frames, 3);
assert_eq!(worker.render_failures, 4);
assert_eq!(worker.publish_failures, 5);
assert_eq!(worker.present_failures, 6);
assert_eq!(worker.stale_submission_discards, 7);
assert_eq!(worker.shutdown_timeouts, 8);
assert_eq!(worker.worker_panics, 9);
assert_eq!(worker.last_produced_frame_id, 10);
assert_eq!(worker.last_consumed_frame_id, 11);
assert_eq!(worker.last_published_frame_id, 12);
assert_eq!(worker.last_presented_frame_id, 13);
assert_eq!(worker.last_dropped_frame_id, 14);
assert_eq!(worker.last_error_frame_id, 15);
assert_eq!(worker.active_render_epoch, 16);
}
}

View File

@ -1,10 +0,0 @@
use crate::glyph::Glyph;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct Tile {
pub active: bool,
pub glyph: Glyph,
pub flip_x: bool,
pub flip_y: bool,
}

View File

@ -1,33 +0,0 @@
use crate::tile::Tile;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TileMap {
pub width: usize,
pub height: usize,
pub tiles: Vec<Tile>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
#[test]
fn tilemap_tile_write_and_read_remain_canonical() {
let mut map = TileMap { width: 2, height: 2, tiles: vec![Tile::default(); 4] };
let index = 3;
map.tiles[index] = Tile {
active: true,
glyph: Glyph { glyph_id: 99, palette_id: 5 },
flip_x: true,
flip_y: false,
};
assert_eq!(map.tiles[index].glyph.glyph_id, 99);
assert_eq!(map.tiles[index].glyph.palette_id, 5);
assert!(map.tiles[index].flip_x);
assert!(map.tiles[index].active);
}
}

View File

@ -1,9 +0,0 @@
use crate::button::Button;
use crate::input_signals::InputSignals;
pub trait TouchBridge {
fn begin_frame(&mut self, signals: &InputSignals);
fn f(&self) -> &Button;
fn x(&self) -> i32;
fn y(&self) -> i32;
}

View File

@ -1,6 +0,0 @@
#[derive(Debug, PartialEq, Clone)]
pub enum VmFault {
Trap(u32, String),
Panic(String),
Unavailable,
}

View File

@ -1,14 +0,0 @@
[package]
name = "prometeu-system"
version = "0.1.0"
edition = "2024"
license.workspace = true
[dependencies]
serde_json = "1.0.149"
prometeu-vm = { path = "../prometeu-vm" }
prometeu-bytecode = { path = "../prometeu-bytecode" }
prometeu-hal = { path = "../prometeu-hal" }
[dev-dependencies]
prometeu-drivers = { path = "../prometeu-drivers" }

View File

@ -1,55 +0,0 @@
use prometeu_bytecode::TrapInfo;
use prometeu_vm::VmInitError;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CrashReport {
VmTrap { trap: TrapInfo },
VmPanic { message: String, pc: Option<u32> },
VmInit { error: VmInitError },
}
impl CrashReport {
pub fn kind(&self) -> &'static str {
match self {
Self::VmTrap { .. } => "vm_trap",
Self::VmPanic { .. } => "vm_panic",
Self::VmInit { .. } => "vm_init",
}
}
pub fn pc(&self) -> Option<u32> {
match self {
Self::VmTrap { trap } => Some(trap.pc),
Self::VmPanic { pc, .. } => *pc,
Self::VmInit { .. } => None,
}
}
pub fn summary(&self) -> String {
match self {
Self::VmTrap { trap } => format!(
"PVM Trap 0x{:08X} at PC 0x{:X} (opcode 0x{:04X}): {}",
trap.code, trap.pc, trap.opcode, trap.message
),
Self::VmPanic { message, pc: Some(pc) } => {
format!("PVM Panic at PC 0x{:X}: {}", pc, message)
}
Self::VmPanic { message, pc: None } => format!("PVM Panic: {}", message),
Self::VmInit { error } => format!("PVM Init Error: {:?}", error),
}
}
pub fn log_tag(&self) -> u16 {
match self {
Self::VmTrap { trap } => trap.code as u16,
Self::VmPanic { .. } | Self::VmInit { .. } => 0,
}
}
}
impl fmt::Display for CrashReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary())
}
}

View File

@ -1,36 +0,0 @@
mod crash_report;
mod os;
mod programs;
mod services;
pub use crash_report::CrashReport;
pub use os::{
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
LifecycleOperation, SessionError, SystemOS,
};
pub use programs::{
HUB_SUSPENDED_GAME_KILL_CONTROL, NativeShellApp, PrometeuHub, SystemProfileAction,
SystemProfileUpdate,
};
pub use prometeu_hal::{RenderWorkerBackend, RenderWorkerFrameSink};
pub use services::async_work::{
AsyncWorkActiveJob, AsyncWorkCancelToken, AsyncWorkJobContext, AsyncWorkJobId,
AsyncWorkJobKind, AsyncWorkJobOutcome, AsyncWorkLane, AsyncWorkLaneConfig,
AsyncWorkLaneController, AsyncWorkLaneError, AsyncWorkLaneTelemetry, AsyncWorkPriority,
};
pub use services::foreground::{
ForegroundOwner, ForegroundStack, ForegroundStackError, ResidentGame, ResidentGameState,
};
pub use services::fs;
pub use services::game_library::{
GameLibrary, GameLibraryDiagnostic, GameLibraryEntry, GameSwitchTarget, discover_games_root,
};
pub use services::memcard::MemcardAsyncLaneOperation;
pub use services::process;
pub use services::task;
pub use services::vm_runtime::{
LatestRenderFrameStore, RenderWorkerConfig, RenderWorkerController, RenderWorkerHandoff,
RenderWorkerHandoffWait, RenderWorkerOwnership, VirtualMachineRuntime,
};
pub use services::vm_session;
pub use services::windows;

View File

@ -1,26 +0,0 @@
use crate::fs::{FsBackend, FsState};
use crate::os::SystemOS;
pub struct FsFacade<'a> {
pub(in crate::os) os: &'a mut SystemOS,
}
impl<'a> FsFacade<'a> {
pub fn needs_mount(&self) -> bool {
matches!(self.os.fs_state, FsState::Unmounted | FsState::Error(_))
}
pub fn mount(&mut self, backend: Box<dyn FsBackend>) {
self.os.vm_runtime.mount_fs(
&mut self.os.log_service,
&mut self.os.fs,
&mut self.os.fs_state,
backend,
);
}
pub fn unmount(&mut self) {
self.os.fs.unmount();
self.os.fs_state = FsState::Unmounted;
}
}

View File

@ -1,299 +0,0 @@
use crate::CrashReport;
use crate::os::SystemOS;
use crate::os::{
DEFAULT_GAME_PAUSE_BUDGET_TICKS, GameLifecycleEvent, GameLifecycleEventKind, LifecycleError,
LifecycleOperation,
};
use crate::process::{Process, ProcessId, ProcessKind, ProcessState};
use crate::services::foreground::{
ForegroundOwner, ForegroundStackError, ResidentGame, ResidentGameState,
};
use crate::task::{TaskId, TaskKind, TaskState};
pub struct LifecycleFacade<'a> {
pub(in crate::os) os: &'a mut SystemOS,
}
impl<'a> LifecycleFacade<'a> {
pub fn task_state(&self, task_id: TaskId) -> Option<TaskState> {
self.os.task_manager.get(task_id).map(|task| task.state)
}
pub fn process_state_for_task(&self, task_id: TaskId) -> Result<ProcessState, LifecycleError> {
process_for_task(self.os, task_id).map(|process| process.state)
}
pub fn process_kind_for_task(&self, task_id: TaskId) -> Result<ProcessKind, LifecycleError> {
process_for_task(self.os, task_id).map(|process| process.kind)
}
pub fn set_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
match task_kind_for_task(self.os, task_id)? {
TaskKind::Game => self.set_game_foreground_task(task_id),
TaskKind::Shell => self.set_shell_foreground_task(task_id),
}
}
pub fn set_game_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let process_id = process_id_for_task(self.os, task_id)?;
if task_kind_for_task(self.os, task_id)? != TaskKind::Game {
return Err(LifecycleError::InvalidTransition {
task_id,
from: self.task_state(task_id).unwrap_or(TaskState::Closed),
operation: LifecycleOperation::SetForeground,
});
}
self.os.foreground_stack.set_game_foreground(task_id).map_err(map_foreground_error)?;
self.os.task_manager.set_foreground(task_id);
self.os.process_manager.mark_running(process_id);
Ok(())
}
pub fn set_shell_foreground_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let process_id = process_id_for_task(self.os, task_id)?;
if task_kind_for_task(self.os, task_id)? != TaskKind::Shell {
return Err(LifecycleError::InvalidTransition {
task_id,
from: self.task_state(task_id).unwrap_or(TaskState::Closed),
operation: LifecycleOperation::SetForeground,
});
}
if let Some(game_task_id) = self.os.foreground_stack.resident_game_task()
&& game_task_id != task_id
&& self.task_state(game_task_id) != Some(TaskState::Suspended)
{
let game_process_id = process_id_for_task(self.os, game_task_id)?;
self.os.task_manager.mark_suspended(game_task_id);
self.os.process_manager.mark_suspended(game_process_id);
}
self.os.foreground_stack.set_shell_foreground(task_id).map_err(map_foreground_error)?;
self.os.task_manager.set_foreground(task_id);
self.os.process_manager.mark_running(process_id);
Ok(())
}
pub fn foreground_owner(&self) -> ForegroundOwner {
self.os.foreground_stack.owner()
}
pub fn resident_game(&self) -> Option<ResidentGame> {
self.os.foreground_stack.resident_game()
}
pub fn resident_game_task(&self) -> Option<TaskId> {
self.os.foreground_stack.resident_game_task()
}
pub fn request_home_from_game(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let _process_id = process_id_for_task(self.os, task_id)?;
let previous = self.os.foreground_stack.resident_game();
self.os
.foreground_stack
.request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS)
.map_err(map_foreground_error)?;
if !matches!(
previous.map(|game| game.state),
Some(ResidentGameState::PauseRequested { .. } | ResidentGameState::PausedSuspended)
) {
self.os
.game_lifecycle_events
.push(GameLifecycleEvent { task_id, kind: GameLifecycleEventKind::Pause });
}
Ok(())
}
pub fn advance_game_pause_budget(&mut self, task_id: TaskId) -> Result<bool, LifecycleError> {
let expired =
self.os.foreground_stack.advance_pause_budget(task_id).map_err(map_foreground_error)?;
if expired {
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.mark_suspended(task_id);
self.os.process_manager.mark_suspended(process_id);
}
Ok(expired)
}
pub fn pending_game_lifecycle_events(&self) -> &[GameLifecycleEvent] {
&self.os.game_lifecycle_events
}
pub fn take_game_lifecycle_events(&mut self) -> Vec<GameLifecycleEvent> {
std::mem::take(&mut self.os.game_lifecycle_events)
}
pub fn return_to_hub(&mut self) {
self.os.foreground_stack.return_to_hub();
self.os.task_manager.clear_foreground();
}
pub fn suspend_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.mark_suspended(task_id);
self.os.process_manager.mark_suspended(process_id);
match self.os.foreground_stack.owner() {
ForegroundOwner::Game(owner) if owner == task_id => {
self.os
.foreground_stack
.request_home_from_game(task_id, DEFAULT_GAME_PAUSE_BUDGET_TICKS)
.map_err(map_foreground_error)?;
self.os
.foreground_stack
.advance_pause_budget(task_id)
.map_err(map_foreground_error)?;
}
ForegroundOwner::Shell(owner) if owner == task_id => {
self.os.foreground_stack.return_to_hub();
}
_ => {}
}
Ok(())
}
pub fn resume_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let task =
self.os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
if task.state != TaskState::Suspended {
return Err(LifecycleError::InvalidTransition {
task_id,
from: task.state,
operation: LifecycleOperation::Resume,
});
}
let process_id = process_id_for_task(self.os, task_id)?;
match task_kind_for_task(self.os, task_id)? {
TaskKind::Game => {
self.os
.foreground_stack
.request_resume_game(task_id)
.map_err(map_foreground_error)?;
self.os.task_manager.set_foreground(task_id);
self.os.process_manager.mark_running(process_id);
self.os.game_lifecycle_events.push(GameLifecycleEvent {
task_id,
kind: GameLifecycleEventKind::ResumeForeground,
});
self.os
.foreground_stack
.complete_resume_game(task_id)
.map_err(map_foreground_error)?;
}
TaskKind::Shell => {
self.os
.foreground_stack
.set_shell_foreground(task_id)
.map_err(map_foreground_error)?;
self.os.task_manager.set_foreground(task_id);
self.os.process_manager.mark_running(process_id);
}
}
Ok(())
}
pub fn close_task(&mut self, task_id: TaskId) -> Result<(), LifecycleError> {
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.close_task(task_id);
self.os.process_manager.mark_stopped(process_id);
if self.os.foreground_stack.resident_game_task() == Some(task_id) {
self.os.foreground_stack.clear_resident_game(task_id);
} else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id)
{
self.os.foreground_stack.return_to_hub();
}
Ok(())
}
pub fn terminate_resident_game(&mut self) -> Result<Option<TaskId>, LifecycleError> {
let Some(task_id) = self.os.foreground_stack.resident_game_task() else {
return Ok(None);
};
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.close_task(task_id);
self.os.process_manager.mark_stopped(process_id);
self.os.foreground_stack.clear_resident_game(task_id);
self.os.vm_sessions.remove_for_task(task_id);
self.os.game_lifecycle_events.retain(|event| event.task_id != task_id);
if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Game(owner) if owner == task_id)
{
self.os.foreground_stack.return_to_hub();
self.os.task_manager.clear_foreground();
}
Ok(Some(task_id))
}
pub fn crash_task(
&mut self,
task_id: TaskId,
_report: Option<&CrashReport>,
) -> Result<(), LifecycleError> {
let process_id = process_id_for_task(self.os, task_id)?;
self.os.task_manager.mark_crashed(task_id);
self.os.process_manager.mark_crashed(process_id);
if self.os.foreground_stack.resident_game_task() == Some(task_id) {
self.os.foreground_stack.clear_resident_game(task_id);
} else if matches!(self.os.foreground_stack.owner(), ForegroundOwner::Shell(owner) if owner == task_id)
{
self.os.foreground_stack.return_to_hub();
}
Ok(())
}
}
fn task_kind_for_task(os: &SystemOS, task_id: TaskId) -> Result<TaskKind, LifecycleError> {
let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
Ok(task.kind)
}
fn process_id_for_task(os: &SystemOS, task_id: TaskId) -> Result<ProcessId, LifecycleError> {
let task = os.task_manager.get(task_id).ok_or(LifecycleError::TaskNotFound(task_id))?;
if !os.process_manager.contains(task.process_id) {
return Err(LifecycleError::ProcessNotFound(task.process_id));
}
Ok(task.process_id)
}
fn process_for_task(os: &SystemOS, task_id: TaskId) -> Result<&Process, LifecycleError> {
let process_id = process_id_for_task(os, task_id)?;
os.process_manager.get(process_id).ok_or(LifecycleError::ProcessNotFound(process_id))
}
fn map_foreground_error(error: ForegroundStackError) -> LifecycleError {
match error {
ForegroundStackError::ResidentGameAlreadyExists { existing, requested } => {
LifecycleError::ResidentGameAlreadyExists { existing, requested }
}
ForegroundStackError::ForegroundShellAlreadyExists { existing, requested } => {
LifecycleError::ForegroundShellAlreadyExists { existing, requested }
}
ForegroundStackError::TaskIsNotResidentGame(task_id) => {
LifecycleError::TaskIsNotResidentGame(task_id)
}
}
}

View File

@ -1,11 +0,0 @@
mod fs;
mod lifecycle;
mod sessions;
mod vm;
mod window;
pub use fs::FsFacade;
pub use lifecycle::LifecycleFacade;
pub use sessions::{SessionError, SessionsFacade};
pub use vm::VmFacade;
pub use window::WindowFacade;

Some files were not shown because too many files have changed in this diff Show More