Compare commits
No commits in common. "merge/master" and "master" have entirely different histories.
merge/mast
...
master
29
.github/workflows/ci.yml
vendored
Normal file
29
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -56,3 +56,8 @@ dist-staging/**
|
||||
|
||||
temp
|
||||
temp/**
|
||||
|
||||
**/build/**
|
||||
**/node_modules/**
|
||||
|
||||
AGENTS.md
|
||||
591
Cargo.lock
generated
591
Cargo.lock
generated
@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
@ -86,7 +86,7 @@ dependencies = [
|
||||
"ndk-context",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -156,9 +156,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
@ -288,7 +288,7 @@ dependencies = [
|
||||
"polling",
|
||||
"rustix 0.38.44",
|
||||
"slab",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -303,15 +303,6 @@ 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"
|
||||
@ -415,7 +406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
|
||||
dependencies = [
|
||||
"termcolor",
|
||||
"unicode-width 0.1.14",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -465,20 +456,6 @@ 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"
|
||||
@ -548,12 +525,6 @@ 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"
|
||||
@ -633,12 +604,6 @@ 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"
|
||||
@ -661,12 +626,6 @@ 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"
|
||||
@ -710,6 +669,17 @@ 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"
|
||||
@ -787,7 +757,7 @@ checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"winapi",
|
||||
"windows 0.52.0",
|
||||
]
|
||||
@ -827,9 +797,6 @@ 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"
|
||||
@ -841,7 +808,7 @@ dependencies = [
|
||||
"com",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"widestring",
|
||||
"winapi",
|
||||
]
|
||||
@ -906,7 +873,7 @@ dependencies = [
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
@ -923,7 +890,7 @@ version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@ -1088,7 +1055,7 @@ dependencies = [
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"termcolor",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@ -1103,7 +1070,7 @@ dependencies = [
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1118,7 +1085,7 @@ dependencies = [
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1155,22 +1122,6 @@ 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"
|
||||
@ -1182,15 +1133,6 @@ 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"
|
||||
@ -1498,212 +1440,6 @@ 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"
|
||||
@ -1733,55 +1469,22 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[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"
|
||||
@ -1817,7 +1520,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"ultraviolet",
|
||||
"wgpu",
|
||||
]
|
||||
@ -1863,6 +1566,15 @@ 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"
|
||||
@ -1893,39 +1605,42 @@ 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-compiler"
|
||||
name = "prometeu-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
"oxc_ast_visit",
|
||||
"oxc_parser",
|
||||
"oxc_span",
|
||||
"prometeu-bytecode",
|
||||
"prometeu-core",
|
||||
"serde",
|
||||
"prometeu-host-desktop-winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometeu-drivers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"prometeu-hal",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometeu-core"
|
||||
name = "prometeu-firmware"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"prometeu-bytecode",
|
||||
"prometeu-drivers",
|
||||
"prometeu-hal",
|
||||
"prometeu-system",
|
||||
"prometeu-vm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometeu-hal"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"prometeu-bytecode",
|
||||
@ -1934,17 +1649,62 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometeu-runtime-desktop"
|
||||
name = "prometeu-host-desktop-winit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"cpal",
|
||||
"pixels",
|
||||
"prometeu-core",
|
||||
"prometeu-drivers",
|
||||
"prometeu-firmware",
|
||||
"prometeu-hal",
|
||||
"prometeu-system",
|
||||
"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"
|
||||
@ -1969,6 +1729,36 @@ 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"
|
||||
@ -2098,12 +1888,6 @@ 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"
|
||||
@ -2147,12 +1931,6 @@ 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"
|
||||
@ -2202,12 +1980,6 @@ 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"
|
||||
@ -2229,12 +2001,6 @@ 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"
|
||||
@ -2249,7 +2015,7 @@ dependencies = [
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 0.38.44",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
@ -2327,33 +2093,13 @@ 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 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",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2367,17 +2113,6 @@ 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"
|
||||
@ -2426,9 +2161,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.0.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
@ -2464,24 +2199,12 @@ 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"
|
||||
@ -2494,12 +2217,6 @@ 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"
|
||||
@ -2528,6 +2245,12 @@ 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"
|
||||
@ -2768,7 +2491,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"web-sys",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
@ -2812,7 +2535,7 @@ dependencies = [
|
||||
"renderdoc-sys",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu-types",
|
||||
|
||||
20
Cargo.toml
20
Cargo.toml
@ -1,10 +1,20 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/prometeu-core",
|
||||
"crates/prometeu-runtime-desktop",
|
||||
"crates/prometeu",
|
||||
"crates/prometeu-bytecode",
|
||||
"crates/prometeu-compiler",
|
||||
"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-stress",
|
||||
"crates/dev/prometeu-test-support",
|
||||
"crates/dev/prometeu-layer-tests",
|
||||
"crates/dev/prometeu-quality-checks",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
@ -0,0 +1,32 @@
|
||||
.PHONY: fmt fmt-check clippy tes-local test-debugger-socket test ci 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-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
|
||||
|
||||
test: fmt-check clippy test-local test-debugger-socket
|
||||
ci: clean fmt-check clippy coverage
|
||||
cobertura: coverage-xml coverage-json
|
||||
108
README.md
108
README.md
@ -1,84 +1,94 @@
|
||||
# PROMETEU
|
||||
# PROMETEU Runtime
|
||||
|
||||
PROMETEU is an **educational and experimental ecosystem** inspired by classic consoles, focusing on **teaching programming, system architecture, and hardware concepts through software**.
|
||||
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, host integration, CLI entrypoints, development utilities, specs, and discussion artifacts.
|
||||
|
||||
> PROMETEU is a simple, explicit, and educational virtual machine.
|
||||
The VM is only one subsystem of the machine. The canonical docs in this repository intentionally distinguish:
|
||||
|
||||
---
|
||||
- machine-level specs;
|
||||
- VM/runtime internal architecture;
|
||||
- implementation crates;
|
||||
- discussion workflow and lessons learned.
|
||||
|
||||
## 🎯 Project Goals
|
||||
## What This Repository Contains
|
||||
|
||||
- **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.
|
||||
- `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
|
||||
|
||||
---
|
||||
## Canonical Documentation
|
||||
|
||||
## 🧠 Design Philosophy
|
||||
Use these entrypoints instead of inferring the model from isolated source files:
|
||||
|
||||
- **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.
|
||||
- [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
|
||||
|
||||
---
|
||||
## Workspace Layout
|
||||
|
||||
## 📦 Monorepo Structure
|
||||
This repository is a Rust workspace rooted at [Cargo.toml](Cargo.toml) with members in:
|
||||
|
||||
This repository is organized as a Rust workspace and contains several components:
|
||||
- `crates/console`
|
||||
- `crates/host`
|
||||
- `crates/tools`
|
||||
- `crates/dev`
|
||||
|
||||
- **[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.
|
||||
The main user-facing dispatcher binary is `prometeu`, built from `crates/tools/prometeu-cli`.
|
||||
|
||||
---
|
||||
Current CLI surface:
|
||||
|
||||
## 🛠️ Requirements
|
||||
- `prometeu run <cart>`
|
||||
- `prometeu debug <cart> --port <port>`
|
||||
- `prometeu build <project-dir>`
|
||||
- `prometeu pack <cart-dir>`
|
||||
- `prometeu verify ...`
|
||||
|
||||
- **Rust**: Version defined in `rust-toolchain.toml`.
|
||||
- **Installation**: Use [rustup](https://rustup.rs/) to install the required toolchain.
|
||||
Not every command is fully implemented in every distribution path yet. Today, the runtime flow is the most concrete and the dispatcher forwards execution to specialized binaries when they are available.
|
||||
|
||||
---
|
||||
## Requirements
|
||||
|
||||
## ▶️ Quick Start
|
||||
- Rust toolchain from [rust-toolchain.toml](rust-toolchain.toml)
|
||||
- `rustup` for toolchain installation and management
|
||||
|
||||
To compile the full project:
|
||||
## Quick Start
|
||||
|
||||
Build the workspace:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
To run an example cartridge:
|
||||
Inspect the CLI:
|
||||
|
||||
```bash
|
||||
./target/debug/prometeu run test-cartridges/color-square
|
||||
cargo run -q -p prometeu-cli --bin prometeu -- --help
|
||||
```
|
||||
|
||||
For more details on how to use the CLI, see the **[prometeu](./crates/prometeu)** README.
|
||||
Run the current stress cartridge fixture:
|
||||
|
||||
---
|
||||
```bash
|
||||
cargo run -q -p prometeu-cli --bin prometeu -- run test-cartridges/stress-console
|
||||
```
|
||||
|
||||
## 🚧 Project Status
|
||||
The desktop runtime opens a native window through the host layer, so this last command is intended for a local graphical environment.
|
||||
|
||||
⚠️ **Early stage (bootstrap)**
|
||||
## Current State
|
||||
|
||||
Currently, the focus is on stabilizing the core architecture and debugging protocol. Nothing here should be considered a stable API yet.
|
||||
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;
|
||||
- some CLI subcommands still act as dispatcher placeholders for binaries that are not always present in a local build or distribution.
|
||||
|
||||
## 📜 License
|
||||
Treat APIs, file formats, and execution flows as evolving unless the relevant spec explicitly defines them as stable.
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Final Note
|
||||
|
||||
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.
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
@ -1 +1 @@
|
||||
0.1.0
|
||||
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
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
|
||||
Binary file not shown.
@ -1,74 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@ -6,4 +6,4 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# No dependencies for now
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
50
crates/console/prometeu-bytecode/src/abi.rs
Normal file
50
crates/console/prometeu-bytecode/src/abi.rs
Normal file
@ -0,0 +1,50 @@
|
||||
//! 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>,
|
||||
}
|
||||
367
crates/console/prometeu-bytecode/src/assembler.rs
Normal file
367
crates/console/prometeu-bytecode/src/assembler.rs
Normal file
@ -0,0 +1,367 @@
|
||||
//! 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)
|
||||
}
|
||||
133
crates/console/prometeu-bytecode/src/decoder.rs
Normal file
133
crates/console/prometeu-bytecode/src/decoder.rs
Normal file
@ -0,0 +1,133 @@
|
||||
//! 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
120
crates/console/prometeu-bytecode/src/disassembler.rs
Normal file
120
crates/console/prometeu-bytecode/src/disassembler.rs
Normal file
@ -0,0 +1,120 @@
|
||||
//! 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"))
|
||||
}
|
||||
12
crates/console/prometeu-bytecode/src/isa/core.rs
Normal file
12
crates/console/prometeu-bytecode/src/isa/core.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! 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};
|
||||
6
crates/console/prometeu-bytecode/src/isa/mod.rs
Normal file
6
crates/console/prometeu-bytecode/src/isa/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
//! 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;
|
||||
98
crates/console/prometeu-bytecode/src/layout.rs
Normal file
98
crates/console/prometeu-bytecode/src/layout.rs
Normal file
@ -0,0 +1,98 @@
|
||||
//! 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
crates/console/prometeu-bytecode/src/lib.rs
Normal file
24
crates/console/prometeu-bytecode/src/lib.rs
Normal file
@ -0,0 +1,24 @@
|
||||
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::{HeapRef, Value};
|
||||
946
crates/console/prometeu-bytecode/src/model.rs
Normal file
946
crates/console/prometeu-bytecode/src/model.rs
Normal file
@ -0,0 +1,946 @@
|
||||
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(¤t_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(§ion_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 §ions {
|
||||
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(§ion_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: "gfx".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));
|
||||
}
|
||||
}
|
||||
324
crates/console/prometeu-bytecode/src/opcode.rs
Normal file
324
crates/console/prometeu-bytecode/src/opcode.rs
Normal file
@ -0,0 +1,324 @@
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
567
crates/console/prometeu-bytecode/src/opcode_spec.rs
Normal file
567
crates/console/prometeu-bytecode/src/opcode_spec.rs
Normal file
@ -0,0 +1,567 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
124
crates/console/prometeu-bytecode/src/program_image.rs
Normal file
124
crates/console/prometeu-bytecode/src/program_image.rs
Normal file
@ -0,0 +1,124 @@
|
||||
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.clone()),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// Opaque handle that references an object stored in the VM heap.
|
||||
///
|
||||
/// This is an index-based handle. It does not imply ownership and carries
|
||||
/// no lifetime information. GC/allocator integration will come in later PRs.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct HeapRef(pub u32);
|
||||
|
||||
/// Represents any piece of data that can be stored on the VM stack or in globals.
|
||||
///
|
||||
/// The PVM is "dynamically typed" at the bytecode level, meaning a single
|
||||
/// `Value` enum can hold different primitive types. The VM performs
|
||||
/// automatic type promotion (e.g., adding an Int32 to a Float64 results
|
||||
/// in a Float64) to ensure mathematical correctness.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Value {
|
||||
/// 32-bit signed integer. Used for most loop counters and indices.
|
||||
Int32(i32),
|
||||
/// 64-bit signed integer. Used for large numbers and timestamps.
|
||||
Int64(i64),
|
||||
/// 64-bit double precision float. Used for physics and complex math.
|
||||
Float(f64),
|
||||
/// Boolean value (true/false).
|
||||
Boolean(bool),
|
||||
/// UTF-8 string. Strings are immutable and usually come from the Constant Pool.
|
||||
String(String),
|
||||
Ref(usize), // Heap reference
|
||||
/// A handle to an object on the heap (opaque reference).
|
||||
HeapRef(HeapRef),
|
||||
/// Represents the absence of a value (equivalent to `null` or `undefined`).
|
||||
Null,
|
||||
}
|
||||
|
||||
@ -27,7 +47,7 @@ impl PartialEq for Value {
|
||||
(Value::Float(a), Value::Int64(b)) => *a == *b as f64,
|
||||
(Value::Boolean(a), Value::Boolean(b)) => a == b,
|
||||
(Value::String(a), Value::String(b)) => a == b,
|
||||
(Value::Ref(a), Value::Ref(b)) => a == b,
|
||||
(Value::HeapRef(a), Value::HeapRef(b)) => a == b,
|
||||
(Value::Null, Value::Null) => true,
|
||||
_ => false,
|
||||
}
|
||||
@ -71,6 +91,19 @@ impl Value {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::inherent_to_string)]
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
Value::Int32(i) => i.to_string(),
|
||||
Value::Int64(i) => i.to_string(),
|
||||
Value::Float(f) => f.to_string(),
|
||||
Value::Boolean(b) => b.to_string(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::HeapRef(r) => format!("[HeapRef {}]", r.0),
|
||||
Value::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
46
crates/console/prometeu-bytecode/tests/disasm_roundtrip.rs
Normal file
46
crates/console/prometeu-bytecode/tests/disasm_roundtrip.rs
Normal file
@ -0,0 +1,46 @@
|
||||
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 gfx.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");
|
||||
}
|
||||
43
crates/console/prometeu-bytecode/tests/disasm_snapshot.rs
Normal file
43
crates/console/prometeu-bytecode/tests/disasm_snapshot.rs
Normal file
@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
160
crates/console/prometeu-bytecode/tests/roundtrip.rs
Normal file
160
crates/console/prometeu-bytecode/tests/roundtrip.rs
Normal file
@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
9
crates/console/prometeu-drivers/Cargo.toml
Normal file
9
crates/console/prometeu-drivers/Cargo.toml
Normal file
@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "prometeu-drivers"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0.149"
|
||||
prometeu-hal = { path = "../prometeu-hal" }
|
||||
1784
crates/console/prometeu-drivers/src/asset.rs
Normal file
1784
crates/console/prometeu-drivers/src/asset.rs
Normal file
File diff suppressed because it is too large
Load Diff
332
crates/console/prometeu-drivers/src/audio.rs
Normal file
332
crates/console/prometeu-drivers/src/audio.rs
Normal file
@ -0,0 +1,332 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
719
crates/console/prometeu-drivers/src/frame_composer.rs
Normal file
719
crates/console/prometeu-drivers/src/frame_composer.rs
Normal file
@ -0,0 +1,719 @@
|
||||
use crate::memory_banks::SceneBankPoolAccess;
|
||||
use prometeu_hal::GfxBridge;
|
||||
use prometeu_hal::glyph::Glyph;
|
||||
use prometeu_hal::scene_bank::SceneBank;
|
||||
use prometeu_hal::scene_viewport_cache::SceneViewportCache;
|
||||
use prometeu_hal::scene_viewport_resolver::{CacheRefreshRequest, SceneViewportResolver};
|
||||
use prometeu_hal::sprite::Sprite;
|
||||
use std::sync::Arc;
|
||||
|
||||
const EMPTY_SPRITE: Sprite = Sprite {
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SceneStatus {
|
||||
#[default]
|
||||
Unbound,
|
||||
Available {
|
||||
scene_bank_id: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SpriteController {
|
||||
sprites: [Sprite; 512],
|
||||
sprite_count: usize,
|
||||
frame_counter: u64,
|
||||
dropped_sprites: usize,
|
||||
layer_buckets: [Vec<usize>; 4],
|
||||
}
|
||||
|
||||
impl Default for SpriteController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpriteController {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sprites: [EMPTY_SPRITE; 512],
|
||||
sprite_count: 0,
|
||||
frame_counter: 0,
|
||||
dropped_sprites: 0,
|
||||
layer_buckets: std::array::from_fn(|_| Vec::with_capacity(128)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sprites(&self) -> &[Sprite; 512] {
|
||||
&self.sprites
|
||||
}
|
||||
|
||||
pub fn sprites_mut(&mut self) -> &mut [Sprite; 512] {
|
||||
&mut self.sprites
|
||||
}
|
||||
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.frame_counter = self.frame_counter.wrapping_add(1);
|
||||
self.sprite_count = 0;
|
||||
self.dropped_sprites = 0;
|
||||
for bucket in &mut self.layer_buckets {
|
||||
bucket.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_sprite(&mut self, mut sprite: Sprite) -> bool {
|
||||
let Some(bucket) = self.layer_buckets.get_mut(sprite.layer as usize) else {
|
||||
self.dropped_sprites += 1;
|
||||
return false;
|
||||
};
|
||||
if self.sprite_count >= self.sprites.len() {
|
||||
self.dropped_sprites += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
sprite.active = true;
|
||||
let index = self.sprite_count;
|
||||
self.sprites[index] = sprite;
|
||||
self.sprite_count += 1;
|
||||
bucket.push(index);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn sprite_count(&self) -> usize {
|
||||
self.sprite_count
|
||||
}
|
||||
|
||||
pub fn frame_counter(&self) -> u64 {
|
||||
self.frame_counter
|
||||
}
|
||||
|
||||
pub fn dropped_sprites(&self) -> usize {
|
||||
self.dropped_sprites
|
||||
}
|
||||
|
||||
pub fn ordered_sprites(&self) -> Vec<Sprite> {
|
||||
let mut ordered = Vec::with_capacity(self.sprite_count);
|
||||
for bucket in &self.layer_buckets {
|
||||
let mut indices = bucket.clone();
|
||||
indices.sort_by_key(|&index| self.sprites[index].priority);
|
||||
for index in indices {
|
||||
ordered.push(self.sprites[index]);
|
||||
}
|
||||
}
|
||||
ordered
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FrameComposer {
|
||||
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
|
||||
viewport_width_px: usize,
|
||||
viewport_height_px: usize,
|
||||
active_scene_id: Option<usize>,
|
||||
active_scene: Option<Arc<SceneBank>>,
|
||||
scene_status: SceneStatus,
|
||||
camera_x_px: i32,
|
||||
camera_y_px: i32,
|
||||
cache: Option<SceneViewportCache>,
|
||||
resolver: Option<SceneViewportResolver>,
|
||||
sprite_controller: SpriteController,
|
||||
}
|
||||
|
||||
impl FrameComposer {
|
||||
pub fn new(
|
||||
viewport_width_px: usize,
|
||||
viewport_height_px: usize,
|
||||
scene_bank_pool: Arc<dyn SceneBankPoolAccess>,
|
||||
) -> Self {
|
||||
Self {
|
||||
scene_bank_pool,
|
||||
viewport_width_px,
|
||||
viewport_height_px,
|
||||
active_scene_id: None,
|
||||
active_scene: None,
|
||||
scene_status: SceneStatus::Unbound,
|
||||
camera_x_px: 0,
|
||||
camera_y_px: 0,
|
||||
cache: None,
|
||||
resolver: None,
|
||||
sprite_controller: SpriteController::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn viewport_size(&self) -> (usize, usize) {
|
||||
(self.viewport_width_px, self.viewport_height_px)
|
||||
}
|
||||
|
||||
pub fn scene_bank_pool(&self) -> &Arc<dyn SceneBankPoolAccess> {
|
||||
&self.scene_bank_pool
|
||||
}
|
||||
|
||||
pub fn scene_bank_slot(&self, slot: usize) -> Option<Arc<SceneBank>> {
|
||||
self.scene_bank_pool.scene_bank_slot(slot)
|
||||
}
|
||||
|
||||
pub fn scene_bank_slot_count(&self) -> usize {
|
||||
self.scene_bank_pool.scene_bank_slot_count()
|
||||
}
|
||||
|
||||
pub fn active_scene_id(&self) -> Option<usize> {
|
||||
self.active_scene_id
|
||||
}
|
||||
|
||||
pub fn active_scene(&self) -> Option<&Arc<SceneBank>> {
|
||||
self.active_scene.as_ref()
|
||||
}
|
||||
|
||||
pub fn scene_status(&self) -> SceneStatus {
|
||||
self.scene_status
|
||||
}
|
||||
|
||||
pub fn camera(&self) -> (i32, i32) {
|
||||
(self.camera_x_px, self.camera_y_px)
|
||||
}
|
||||
|
||||
pub fn bind_scene(&mut self, scene_bank_id: usize) -> bool {
|
||||
let Some(scene) = self.scene_bank_pool.scene_bank_slot(scene_bank_id) else {
|
||||
self.unbind_scene();
|
||||
return false;
|
||||
};
|
||||
|
||||
let (cache, resolver) =
|
||||
Self::build_scene_runtime(self.viewport_width_px, self.viewport_height_px, &scene);
|
||||
self.active_scene_id = Some(scene_bank_id);
|
||||
self.active_scene = Some(scene);
|
||||
self.scene_status = SceneStatus::Available { scene_bank_id };
|
||||
self.cache = Some(cache);
|
||||
self.resolver = Some(resolver);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn unbind_scene(&mut self) {
|
||||
self.active_scene_id = None;
|
||||
self.active_scene = None;
|
||||
self.scene_status = SceneStatus::Unbound;
|
||||
self.cache = None;
|
||||
self.resolver = None;
|
||||
}
|
||||
|
||||
pub fn set_camera(&mut self, x: i32, y: i32) {
|
||||
self.camera_x_px = x;
|
||||
self.camera_y_px = y;
|
||||
}
|
||||
|
||||
pub fn cache(&self) -> Option<&SceneViewportCache> {
|
||||
self.cache.as_ref()
|
||||
}
|
||||
|
||||
pub fn resolver(&self) -> Option<&SceneViewportResolver> {
|
||||
self.resolver.as_ref()
|
||||
}
|
||||
|
||||
pub fn sprite_controller(&self) -> &SpriteController {
|
||||
&self.sprite_controller
|
||||
}
|
||||
|
||||
pub fn sprite_controller_mut(&mut self) -> &mut SpriteController {
|
||||
&mut self.sprite_controller
|
||||
}
|
||||
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.sprite_controller.begin_frame();
|
||||
}
|
||||
|
||||
pub fn emit_sprite(&mut self, sprite: Sprite) -> bool {
|
||||
self.sprite_controller.emit_sprite(sprite)
|
||||
}
|
||||
|
||||
pub fn ordered_sprites(&self) -> Vec<Sprite> {
|
||||
self.sprite_controller.ordered_sprites()
|
||||
}
|
||||
|
||||
pub fn render_frame(&mut self, gfx: &mut dyn GfxBridge) {
|
||||
let ordered_sprites = self.ordered_sprites();
|
||||
gfx.load_frame_sprites(&ordered_sprites);
|
||||
|
||||
if let (Some(scene), Some(cache), Some(resolver)) =
|
||||
(self.active_scene.as_deref(), self.cache.as_mut(), self.resolver.as_mut())
|
||||
{
|
||||
let update = resolver.update(scene, self.camera_x_px, self.camera_y_px);
|
||||
Self::apply_refresh_requests(cache, scene, &update.refresh_requests);
|
||||
// `FrameComposer` owns only canonical game-frame composition.
|
||||
// Deferred `gfx.*` primitives are drained later by a separate
|
||||
// overlay/debug stage outside this service boundary.
|
||||
gfx.render_scene_from_cache(cache, &update);
|
||||
return;
|
||||
}
|
||||
|
||||
// No-scene frames still stop at canonical game composition. Final
|
||||
// overlay/debug work remains outside `FrameComposer`.
|
||||
gfx.render_no_scene_frame();
|
||||
}
|
||||
|
||||
fn build_scene_runtime(
|
||||
viewport_width_px: usize,
|
||||
viewport_height_px: usize,
|
||||
scene: &SceneBank,
|
||||
) -> (SceneViewportCache, SceneViewportResolver) {
|
||||
let min_tile_px =
|
||||
scene.layers.iter().map(|layer| layer.tile_size as usize).min().unwrap_or(8);
|
||||
let cache_width_tiles = viewport_width_px.div_ceil(min_tile_px) + 5;
|
||||
let cache_height_tiles = viewport_height_px.div_ceil(min_tile_px) + 4;
|
||||
let hysteresis_safe_px = min_tile_px.saturating_sub(4) as i32;
|
||||
let hysteresis_trigger_px = (min_tile_px + 4) as i32;
|
||||
|
||||
(
|
||||
SceneViewportCache::new(scene, cache_width_tiles, cache_height_tiles),
|
||||
SceneViewportResolver::new(
|
||||
viewport_width_px as i32,
|
||||
viewport_height_px as i32,
|
||||
cache_width_tiles,
|
||||
cache_height_tiles,
|
||||
hysteresis_safe_px,
|
||||
hysteresis_trigger_px,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_refresh_requests(
|
||||
cache: &mut SceneViewportCache,
|
||||
scene: &SceneBank,
|
||||
refresh_requests: &[CacheRefreshRequest],
|
||||
) {
|
||||
for request in refresh_requests {
|
||||
match *request {
|
||||
CacheRefreshRequest::InvalidateLayer { layer_index } => {
|
||||
cache.layers[layer_index].invalidate_all();
|
||||
}
|
||||
CacheRefreshRequest::RefreshLine { layer_index, cache_y } => {
|
||||
cache.refresh_layer_line(scene, layer_index, cache_y);
|
||||
}
|
||||
CacheRefreshRequest::RefreshColumn { layer_index, cache_x } => {
|
||||
cache.refresh_layer_column(scene, layer_index, cache_x);
|
||||
}
|
||||
CacheRefreshRequest::RefreshRegion { layer_index, region } => {
|
||||
cache.refresh_layer_region(scene, layer_index, region);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gfx::Gfx;
|
||||
use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolInstaller,
|
||||
};
|
||||
use prometeu_hal::color::Color;
|
||||
use prometeu_hal::glyph_bank::{GlyphBank, TileSize};
|
||||
use prometeu_hal::scene_layer::{ParallaxFactor, SceneLayer};
|
||||
use prometeu_hal::tile::Tile;
|
||||
use prometeu_hal::tilemap::TileMap;
|
||||
|
||||
fn make_scene() -> SceneBank {
|
||||
make_scene_with_palette(1, 1, TileSize::Size8)
|
||||
}
|
||||
|
||||
fn make_scene_with_palette(
|
||||
glyph_bank_id: u8,
|
||||
palette_id: u8,
|
||||
tile_size: TileSize,
|
||||
) -> SceneBank {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id,
|
||||
tile_size,
|
||||
parallax_factor: ParallaxFactor { x: 1.0, y: 0.5 },
|
||||
tilemap: TileMap {
|
||||
width: 2,
|
||||
height: 2,
|
||||
tiles: vec![
|
||||
Tile {
|
||||
active: true,
|
||||
glyph: Glyph { glyph_id: 0, palette_id },
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
};
|
||||
4
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SceneBank { layers: std::array::from_fn(|_| layer.clone()) }
|
||||
}
|
||||
|
||||
fn make_glyph_bank(tile_size: TileSize, palette_id: u8, color: Color) -> GlyphBank {
|
||||
let size = tile_size as usize;
|
||||
let mut bank = GlyphBank::new(tile_size, size, size);
|
||||
bank.palettes[palette_id as usize][1] = color;
|
||||
for pixel in &mut bank.pixel_indices {
|
||||
*pixel = 1;
|
||||
}
|
||||
bank
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_composer_starts_unbound_with_empty_owned_state() {
|
||||
let frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
|
||||
|
||||
assert_eq!(frame_composer.viewport_size(), (320, 180));
|
||||
assert_eq!(frame_composer.active_scene_id(), None);
|
||||
assert!(frame_composer.active_scene().is_none());
|
||||
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
|
||||
assert_eq!(frame_composer.camera(), (0, 0));
|
||||
assert!(frame_composer.cache().is_none());
|
||||
assert!(frame_composer.resolver().is_none());
|
||||
assert_eq!(frame_composer.sprite_controller().sprites().len(), 512);
|
||||
assert_eq!(frame_composer.sprite_controller().sprite_count(), 0);
|
||||
assert_eq!(frame_composer.sprite_controller().dropped_sprites(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_composer_exposes_shared_scene_bank_access() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_scene_bank(3, Arc::new(make_scene()));
|
||||
|
||||
let frame_composer = FrameComposer::new(320, 180, banks);
|
||||
let scene =
|
||||
frame_composer.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
|
||||
|
||||
assert_eq!(frame_composer.scene_bank_slot_count(), 16);
|
||||
assert_eq!(scene.layers[0].tile_size, TileSize::Size8);
|
||||
assert_eq!(scene.layers[0].parallax_factor.y, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_scene_stores_scene_identity_and_shared_reference() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_scene_bank(3, Arc::new(make_scene()));
|
||||
|
||||
let expected_scene =
|
||||
banks.scene_bank_slot(3).expect("scene bank slot 3 should be resident");
|
||||
let mut frame_composer = FrameComposer::new(320, 180, banks);
|
||||
|
||||
assert!(frame_composer.bind_scene(3));
|
||||
|
||||
assert_eq!(frame_composer.active_scene_id(), Some(3));
|
||||
assert!(Arc::ptr_eq(
|
||||
frame_composer.active_scene().expect("active scene should exist"),
|
||||
&expected_scene,
|
||||
));
|
||||
assert_eq!(frame_composer.scene_status(), SceneStatus::Available { scene_bank_id: 3 });
|
||||
assert!(frame_composer.cache().is_some());
|
||||
assert!(frame_composer.resolver().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbind_scene_clears_scene_and_cache_state() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_scene_bank(1, Arc::new(make_scene()));
|
||||
|
||||
let mut frame_composer = FrameComposer::new(320, 180, banks);
|
||||
assert!(frame_composer.bind_scene(1));
|
||||
|
||||
frame_composer.unbind_scene();
|
||||
|
||||
assert_eq!(frame_composer.active_scene_id(), None);
|
||||
assert!(frame_composer.active_scene().is_none());
|
||||
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
|
||||
assert!(frame_composer.cache().is_none());
|
||||
assert!(frame_composer.resolver().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_camera_stores_top_left_pixel_coordinates() {
|
||||
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
|
||||
|
||||
frame_composer.set_camera(-12, 48);
|
||||
|
||||
assert_eq!(frame_composer.camera(), (-12, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_scene_derives_cache_and_resolver_from_eight_pixel_layers() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_scene_bank(0, Arc::new(make_scene()));
|
||||
|
||||
let mut frame_composer = FrameComposer::new(320, 180, banks);
|
||||
assert!(frame_composer.bind_scene(0));
|
||||
|
||||
let cache = frame_composer.cache().expect("cache should exist for bound scene");
|
||||
assert_eq!((cache.width(), cache.height()), (45, 27));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_scene_binding_falls_back_to_no_scene_state() {
|
||||
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
|
||||
|
||||
assert!(!frame_composer.bind_scene(7));
|
||||
|
||||
assert_eq!(frame_composer.scene_status(), SceneStatus::Unbound);
|
||||
assert!(frame_composer.cache().is_none());
|
||||
assert!(frame_composer.resolver().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_controller_begin_frame_resets_sprite_count_and_buckets() {
|
||||
let mut controller = SpriteController::new();
|
||||
let emitted = controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 1, palette_id: 2 },
|
||||
x: 4,
|
||||
y: 5,
|
||||
layer: 2,
|
||||
bank_id: 3,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 1,
|
||||
});
|
||||
assert!(emitted);
|
||||
|
||||
controller.begin_frame();
|
||||
|
||||
assert_eq!(controller.frame_counter(), 1);
|
||||
assert_eq!(controller.sprite_count(), 0);
|
||||
assert_eq!(controller.dropped_sprites(), 0);
|
||||
assert!(controller.ordered_sprites().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_controller_orders_by_layer_then_priority_then_fifo() {
|
||||
let mut controller = SpriteController::new();
|
||||
controller.begin_frame();
|
||||
|
||||
assert!(controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 10, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 1,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 2,
|
||||
}));
|
||||
assert!(controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 11, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 3,
|
||||
}));
|
||||
assert!(controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 12, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 1,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 1,
|
||||
}));
|
||||
assert!(controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 13, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 1,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 2,
|
||||
}));
|
||||
|
||||
let ordered = controller.ordered_sprites();
|
||||
let ordered_ids: Vec<u16> = ordered.iter().map(|sprite| sprite.glyph.glyph_id).collect();
|
||||
|
||||
assert_eq!(ordered_ids, vec![11, 12, 10, 13]);
|
||||
assert!(ordered.iter().all(|sprite| sprite.active));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_controller_drops_overflow_without_panicking() {
|
||||
let mut controller = SpriteController::new();
|
||||
controller.begin_frame();
|
||||
|
||||
for glyph_id in 0..512 {
|
||||
assert!(controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let overflowed = controller.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 999, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 0,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
});
|
||||
|
||||
assert!(!overflowed);
|
||||
assert_eq!(controller.sprite_count(), 512);
|
||||
assert_eq!(controller.dropped_sprites(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_composer_emits_ordered_sprites_for_rendering() {
|
||||
let mut frame_composer = FrameComposer::new(320, 180, Arc::new(MemoryBanks::new()));
|
||||
frame_composer.begin_frame();
|
||||
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 21, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 2,
|
||||
bank_id: 1,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 1,
|
||||
}));
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 20, palette_id: 0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 1,
|
||||
bank_id: 1,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
}));
|
||||
|
||||
let ordered = frame_composer.ordered_sprites();
|
||||
|
||||
assert_eq!(ordered.len(), 2);
|
||||
assert_eq!(ordered[0].glyph.glyph_id, 20);
|
||||
assert_eq!(ordered[1].glyph.glyph_id, 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_without_scene_uses_sprite_only_path() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
frame_composer.begin_frame();
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 3 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 1,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
}));
|
||||
|
||||
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
|
||||
frame_composer.render_frame(&mut gfx);
|
||||
gfx.present();
|
||||
|
||||
assert_eq!(gfx.front_buffer()[0], Color::WHITE.raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_with_scene_applies_refreshes_before_composition() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
|
||||
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 2, TileSize::Size8)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
assert!(frame_composer.bind_scene(0));
|
||||
|
||||
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
|
||||
frame_composer.render_frame(&mut gfx);
|
||||
gfx.present();
|
||||
|
||||
assert!(
|
||||
frame_composer
|
||||
.cache()
|
||||
.expect("cache should exist")
|
||||
.layers
|
||||
.iter()
|
||||
.all(|layer| layer.valid)
|
||||
);
|
||||
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_survives_scene_transition_through_unbind_and_rebind() {
|
||||
let banks = Arc::new(MemoryBanks::new());
|
||||
banks.install_glyph_bank(0, Arc::new(make_glyph_bank(TileSize::Size8, 1, Color::RED)));
|
||||
banks.install_glyph_bank(1, Arc::new(make_glyph_bank(TileSize::Size8, 2, Color::BLUE)));
|
||||
banks.install_glyph_bank(2, Arc::new(make_glyph_bank(TileSize::Size8, 3, Color::WHITE)));
|
||||
banks.install_scene_bank(0, Arc::new(make_scene_with_palette(0, 1, TileSize::Size8)));
|
||||
banks.install_scene_bank(1, Arc::new(make_scene_with_palette(1, 2, TileSize::Size8)));
|
||||
|
||||
let mut frame_composer =
|
||||
FrameComposer::new(16, 16, Arc::clone(&banks) as Arc<dyn SceneBankPoolAccess>);
|
||||
let mut gfx = Gfx::new(16, 16, Arc::clone(&banks) as Arc<dyn GlyphBankPoolAccess>);
|
||||
gfx.scene_fade_level = 31;
|
||||
gfx.hud_fade_level = 31;
|
||||
|
||||
assert!(frame_composer.bind_scene(0));
|
||||
frame_composer.render_frame(&mut gfx);
|
||||
gfx.present();
|
||||
assert_eq!(gfx.front_buffer()[0], Color::RED.raw());
|
||||
|
||||
frame_composer.unbind_scene();
|
||||
frame_composer.begin_frame();
|
||||
assert!(frame_composer.emit_sprite(Sprite {
|
||||
glyph: Glyph { glyph_id: 0, palette_id: 3 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
layer: 0,
|
||||
bank_id: 2,
|
||||
active: false,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
priority: 0,
|
||||
}));
|
||||
frame_composer.render_frame(&mut gfx);
|
||||
gfx.present();
|
||||
assert_eq!(gfx.front_buffer()[0], Color::WHITE.raw());
|
||||
|
||||
frame_composer.begin_frame();
|
||||
assert!(frame_composer.bind_scene(1));
|
||||
frame_composer.render_frame(&mut gfx);
|
||||
gfx.present();
|
||||
assert_eq!(gfx.front_buffer()[0], Color::BLUE.raw());
|
||||
}
|
||||
}
|
||||
1241
crates/console/prometeu-drivers/src/gfx.rs
Normal file
1241
crates/console/prometeu-drivers/src/gfx.rs
Normal file
File diff suppressed because it is too large
Load Diff
39
crates/console/prometeu-drivers/src/gfx_overlay.rs
Normal file
39
crates/console/prometeu-drivers/src/gfx_overlay.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use crate::gfx::BlendMode;
|
||||
use prometeu_hal::color::Color;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayCommand {
|
||||
FillRectBlend { x: i32, y: i32, w: i32, h: i32, color: Color, mode: BlendMode },
|
||||
DrawLine { x0: i32, y0: i32, x1: i32, y1: i32, color: Color },
|
||||
DrawCircle { x: i32, y: i32, r: i32, color: Color },
|
||||
DrawDisc { x: i32, y: i32, r: i32, border_color: Color, fill_color: Color },
|
||||
DrawSquare { x: i32, y: i32, w: i32, h: i32, border_color: Color, fill_color: Color },
|
||||
DrawText { x: i32, y: i32, text: String, color: Color },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DeferredGfxOverlay {
|
||||
commands: Vec<OverlayCommand>,
|
||||
}
|
||||
|
||||
impl DeferredGfxOverlay {
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.commands.clear();
|
||||
}
|
||||
|
||||
pub fn push(&mut self, command: OverlayCommand) {
|
||||
self.commands.push(command);
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> &[OverlayCommand] {
|
||||
&self.commands
|
||||
}
|
||||
|
||||
pub fn command_count(&self) -> usize {
|
||||
self.commands.len()
|
||||
}
|
||||
|
||||
pub fn take_commands(&mut self) -> Vec<OverlayCommand> {
|
||||
std::mem::take(&mut self.commands)
|
||||
}
|
||||
}
|
||||
240
crates/console/prometeu-drivers/src/hardware.rs
Normal file
240
crates/console/prometeu-drivers/src/hardware.rs
Normal file
@ -0,0 +1,240 @@
|
||||
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, GfxBridge, HardwareBridge, PadBridge, TouchBridge};
|
||||
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**: 320x180 (16:9 Aspect Ratio).
|
||||
/// - **Color Depth**: RGB565 (16-bit).
|
||||
/// - **Audio**: Stereo, Command-based mixing.
|
||||
/// - **Input**: 12-button Digital Gamepad + Absolute Touch/Mouse.
|
||||
pub struct Hardware {
|
||||
/// 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 HardwareBridge for Hardware {
|
||||
fn begin_frame(&mut self) {
|
||||
self.gfx.begin_overlay_frame();
|
||||
self.frame_composer.begin_frame();
|
||||
}
|
||||
|
||||
fn bind_scene(&mut self, scene_bank_id: usize) -> bool {
|
||||
self.frame_composer.bind_scene(scene_bank_id)
|
||||
}
|
||||
|
||||
fn unbind_scene(&mut self) {
|
||||
self.frame_composer.unbind_scene();
|
||||
}
|
||||
|
||||
fn set_camera(&mut self, x: i32, y: i32) {
|
||||
self.frame_composer.set_camera(x, y);
|
||||
}
|
||||
|
||||
fn emit_sprite(&mut self, sprite: Sprite) -> bool {
|
||||
self.frame_composer.emit_sprite(sprite)
|
||||
}
|
||||
|
||||
fn render_frame(&mut self) {
|
||||
self.frame_composer.render_frame(&mut self.gfx);
|
||||
self.gfx.drain_overlay_debug();
|
||||
}
|
||||
|
||||
fn has_glyph_bank(&self, bank_id: usize) -> bool {
|
||||
self.gfx.glyph_banks.glyph_bank_slot(bank_id).is_some()
|
||||
}
|
||||
|
||||
fn gfx(&self) -> &dyn GfxBridge {
|
||||
&self.gfx
|
||||
}
|
||||
fn gfx_mut(&mut self) -> &mut dyn GfxBridge {
|
||||
&mut self.gfx
|
||||
}
|
||||
|
||||
fn audio(&self) -> &dyn AudioBridge {
|
||||
&self.audio
|
||||
}
|
||||
fn audio_mut(&mut self) -> &mut dyn AudioBridge {
|
||||
&mut self.audio
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn assets(&self) -> &dyn AssetBridge {
|
||||
&self.assets
|
||||
}
|
||||
fn assets_mut(&mut self) -> &mut dyn AssetBridge {
|
||||
&mut self.assets
|
||||
}
|
||||
}
|
||||
|
||||
impl Hardware {
|
||||
/// Internal hardware width in pixels.
|
||||
pub const W: usize = 320;
|
||||
/// Internal hardware height in pixels.
|
||||
pub const H: usize = 180;
|
||||
|
||||
/// 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 {
|
||||
Self {
|
||||
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>,
|
||||
),
|
||||
audio: Audio::new(Arc::clone(&memory_banks) as Arc<dyn SoundBankPoolAccess>),
|
||||
pad: Pad::default(),
|
||||
touch: Touch::default(),
|
||||
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>,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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::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;
|
||||
|
||||
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 {
|
||||
let layer = SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_id: 0,
|
||||
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()));
|
||||
|
||||
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.scene_fade_level = 31;
|
||||
hardware.gfx.hud_fade_level = 31;
|
||||
hardware.gfx.render_scene_from_cache(&cache, &update);
|
||||
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);
|
||||
}
|
||||
}
|
||||
20
crates/console/prometeu-drivers/src/lib.rs
Normal file
20
crates/console/prometeu-drivers/src/lib.rs
Normal file
@ -0,0 +1,20 @@
|
||||
mod asset;
|
||||
mod audio;
|
||||
mod frame_composer;
|
||||
mod gfx;
|
||||
mod gfx_overlay;
|
||||
pub mod hardware;
|
||||
mod memory_banks;
|
||||
mod pad;
|
||||
mod touch;
|
||||
|
||||
pub use crate::asset::AssetManager;
|
||||
pub use crate::audio::{Audio, AudioCommand, Channel, MAX_CHANNELS, OUTPUT_SAMPLE_RATE};
|
||||
pub use crate::frame_composer::{FrameComposer, SceneStatus, SpriteController};
|
||||
pub use crate::gfx::Gfx;
|
||||
pub use crate::gfx_overlay::{DeferredGfxOverlay, OverlayCommand};
|
||||
pub use crate::memory_banks::{
|
||||
GlyphBankPoolAccess, GlyphBankPoolInstaller, MemoryBanks, SceneBankPoolAccess,
|
||||
SceneBankPoolInstaller, SoundBankPoolAccess, SoundBankPoolInstaller,
|
||||
};
|
||||
pub use crate::pad::Pad;
|
||||
134
crates/console/prometeu-drivers/src/memory_banks.rs
Normal file
134
crates/console/prometeu-drivers/src/memory_banks.rs
Normal file
@ -0,0 +1,134 @@
|
||||
use prometeu_hal::glyph_bank::GlyphBank;
|
||||
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>);
|
||||
}
|
||||
|
||||
/// 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>);
|
||||
}
|
||||
|
||||
/// 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>);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::model::Button;
|
||||
use crate::hardware::input_signal::InputSignals;
|
||||
use prometeu_hal::button::Button;
|
||||
use prometeu_hal::{InputSignals, PadBridge};
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub struct Pad {
|
||||
@ -19,6 +19,52 @@ pub struct Pad {
|
||||
pub select: Button,
|
||||
}
|
||||
|
||||
impl PadBridge for Pad {
|
||||
fn begin_frame(&mut self, signals: &InputSignals) {
|
||||
self.begin_frame(signals)
|
||||
}
|
||||
fn any(&self) -> bool {
|
||||
self.any()
|
||||
}
|
||||
|
||||
fn up(&self) -> &Button {
|
||||
&self.up
|
||||
}
|
||||
fn down(&self) -> &Button {
|
||||
&self.down
|
||||
}
|
||||
fn left(&self) -> &Button {
|
||||
&self.left
|
||||
}
|
||||
fn right(&self) -> &Button {
|
||||
&self.right
|
||||
}
|
||||
fn a(&self) -> &Button {
|
||||
&self.a
|
||||
}
|
||||
fn b(&self) -> &Button {
|
||||
&self.b
|
||||
}
|
||||
fn x(&self) -> &Button {
|
||||
&self.x
|
||||
}
|
||||
fn y(&self) -> &Button {
|
||||
&self.y
|
||||
}
|
||||
fn l(&self) -> &Button {
|
||||
&self.l
|
||||
}
|
||||
fn r(&self) -> &Button {
|
||||
&self.r
|
||||
}
|
||||
fn start(&self) -> &Button {
|
||||
&self.start
|
||||
}
|
||||
fn select(&self) -> &Button {
|
||||
&self.select
|
||||
}
|
||||
}
|
||||
|
||||
impl Pad {
|
||||
pub fn begin_frame(&mut self, signals: &InputSignals) {
|
||||
self.up.begin_frame(signals.up_signal);
|
||||
@ -48,5 +94,3 @@ impl Pad {
|
||||
|| self.select.down
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
crates/console/prometeu-drivers/src/touch.rs
Normal file
33
crates/console/prometeu-drivers/src/touch.rs
Normal file
@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
14
crates/console/prometeu-firmware/Cargo.toml
Normal file
14
crates/console/prometeu-firmware/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[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" }
|
||||
10
crates/console/prometeu-firmware/src/firmware/boot_target.rs
Normal file
10
crates/console/prometeu-firmware/src/firmware/boot_target.rs
Normal file
@ -0,0 +1,10 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum BootTarget {
|
||||
#[default]
|
||||
Hub,
|
||||
Cartridge {
|
||||
path: String,
|
||||
debug: bool,
|
||||
debug_port: u16,
|
||||
},
|
||||
}
|
||||
271
crates/console/prometeu-firmware/src/firmware/firmware.rs
Normal file
271
crates/console/prometeu-firmware/src/firmware/firmware.rs
Normal file
@ -0,0 +1,271 @@
|
||||
use crate::firmware::boot_target::BootTarget;
|
||||
use crate::firmware::firmware_state::{FirmwareState, LoadCartridgeStep, ResetStep};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use prometeu_hal::cartridge::Cartridge;
|
||||
use prometeu_hal::telemetry::CertificationConfig;
|
||||
use prometeu_hal::{HardwareBridge, InputSignals};
|
||||
use prometeu_system::{PrometeuHub, VirtualMachineRuntime};
|
||||
use prometeu_vm::VirtualMachine;
|
||||
|
||||
/// PROMETEU Firmware.
|
||||
///
|
||||
/// The central orchestrator of the console. The firmware acts as the
|
||||
/// "Control Unit", managing the high-level state machine of the system.
|
||||
///
|
||||
/// It is responsible for transitioning between different modes of operation,
|
||||
/// such as showing the splash screen, running the Hub (launcher), or
|
||||
/// executing a game/app.
|
||||
///
|
||||
/// ### Execution Loop:
|
||||
/// The firmware is designed to be ticked once per frame (60Hz). During each
|
||||
/// tick, it:
|
||||
/// 1. Updates peripherals with the latest input signals.
|
||||
/// 2. Delegates the logic update to the current active state.
|
||||
/// 3. Handles state transitions (e.g., from Loading to Playing).
|
||||
pub struct Firmware {
|
||||
/// The execution engine (PVM) for user applications.
|
||||
pub vm: VirtualMachine,
|
||||
/// The underlying OS services (Syscalls, Filesystem, Telemetry).
|
||||
pub os: VirtualMachineRuntime,
|
||||
/// The internal state of the system launcher (Hub).
|
||||
pub hub: PrometeuHub,
|
||||
/// The current operational state (e.g., Reset, SplashScreen, GameRunning).
|
||||
pub state: FirmwareState,
|
||||
/// The desired application to run after boot (Hub or specific Cartridge).
|
||||
pub boot_target: BootTarget,
|
||||
/// State-machine lifecycle tracker.
|
||||
state_initialized: bool,
|
||||
}
|
||||
|
||||
impl Firmware {
|
||||
/// Initializes the firmware in the `Reset` state.
|
||||
pub fn new(cap_config: Option<CertificationConfig>) -> Self {
|
||||
Self {
|
||||
vm: VirtualMachine::default(),
|
||||
os: VirtualMachineRuntime::new(cap_config),
|
||||
hub: PrometeuHub::new(),
|
||||
state: FirmwareState::Reset(ResetStep),
|
||||
boot_target: BootTarget::Hub,
|
||||
state_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The main entry point for the Host to advance the system logic.
|
||||
///
|
||||
/// This method is called exactly once per Host frame (60Hz).
|
||||
/// It updates peripheral signals and delegates the logic to the current state.
|
||||
pub fn tick(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
|
||||
// 0. Process asset commits at the beginning of the frame boundary.
|
||||
hw.assets_mut().apply_commits();
|
||||
|
||||
// 1. Update the peripheral state using the latest signals from the Host.
|
||||
// This ensures input is consistent throughout the entire update.
|
||||
hw.pad_mut().begin_frame(signals);
|
||||
hw.touch_mut().begin_frame(signals);
|
||||
|
||||
// 2. State machine lifecycle management.
|
||||
if !self.state_initialized {
|
||||
self.on_enter(signals, hw);
|
||||
self.state_initialized = true;
|
||||
}
|
||||
|
||||
// 3. Update the current state and check for transitions.
|
||||
if let Some(next_state) = self.on_update(signals, hw) {
|
||||
self.change_state(next_state, signals, hw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transitions the system to a new state, handling lifecycle hooks.
|
||||
pub fn change_state(
|
||||
&mut self,
|
||||
new_state: FirmwareState,
|
||||
signals: &InputSignals,
|
||||
hw: &mut dyn HardwareBridge,
|
||||
) {
|
||||
self.on_exit(signals, hw);
|
||||
self.state = new_state;
|
||||
self.state_initialized = false;
|
||||
|
||||
// Enter the new state immediately to avoid "empty" frames during transitions.
|
||||
self.on_enter(signals, hw);
|
||||
self.state_initialized = true;
|
||||
}
|
||||
|
||||
/// Dispatches the `on_enter` event to the current state implementation.
|
||||
fn on_enter(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
|
||||
let mut req = PrometeuContext {
|
||||
vm: &mut self.vm,
|
||||
os: &mut self.os,
|
||||
hub: &mut self.hub,
|
||||
boot_target: &self.boot_target,
|
||||
signals,
|
||||
hw,
|
||||
};
|
||||
match &mut self.state {
|
||||
FirmwareState::Reset(s) => s.on_enter(&mut req),
|
||||
FirmwareState::SplashScreen(s) => s.on_enter(&mut req),
|
||||
FirmwareState::LaunchHub(s) => s.on_enter(&mut req),
|
||||
FirmwareState::HubHome(s) => s.on_enter(&mut req),
|
||||
FirmwareState::LoadCartridge(s) => s.on_enter(&mut req),
|
||||
FirmwareState::GameRunning(s) => s.on_enter(&mut req),
|
||||
FirmwareState::AppCrashes(s) => s.on_enter(&mut req),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches the `on_update` event to the current state implementation.
|
||||
/// Returns an optional `FirmwareState` if a transition is requested.
|
||||
fn on_update(
|
||||
&mut self,
|
||||
signals: &InputSignals,
|
||||
hw: &mut dyn HardwareBridge,
|
||||
) -> Option<FirmwareState> {
|
||||
let mut req = PrometeuContext {
|
||||
vm: &mut self.vm,
|
||||
os: &mut self.os,
|
||||
hub: &mut self.hub,
|
||||
boot_target: &self.boot_target,
|
||||
signals,
|
||||
hw,
|
||||
};
|
||||
match &mut self.state {
|
||||
FirmwareState::Reset(s) => s.on_update(&mut req),
|
||||
FirmwareState::SplashScreen(s) => s.on_update(&mut req),
|
||||
FirmwareState::LaunchHub(s) => s.on_update(&mut req),
|
||||
FirmwareState::HubHome(s) => s.on_update(&mut req),
|
||||
FirmwareState::LoadCartridge(s) => s.on_update(&mut req),
|
||||
FirmwareState::GameRunning(s) => s.on_update(&mut req),
|
||||
FirmwareState::AppCrashes(s) => s.on_update(&mut req),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches the `on_exit` event to the current state implementation.
|
||||
fn on_exit(&mut self, signals: &InputSignals, hw: &mut dyn HardwareBridge) {
|
||||
let mut req = PrometeuContext {
|
||||
vm: &mut self.vm,
|
||||
os: &mut self.os,
|
||||
hub: &mut self.hub,
|
||||
boot_target: &self.boot_target,
|
||||
signals,
|
||||
hw,
|
||||
};
|
||||
match &mut self.state {
|
||||
FirmwareState::Reset(s) => s.on_exit(&mut req),
|
||||
FirmwareState::SplashScreen(s) => s.on_exit(&mut req),
|
||||
FirmwareState::LaunchHub(s) => s.on_exit(&mut req),
|
||||
FirmwareState::HubHome(s) => s.on_exit(&mut req),
|
||||
FirmwareState::LoadCartridge(s) => s.on_exit(&mut req),
|
||||
FirmwareState::GameRunning(s) => s.on_exit(&mut req),
|
||||
FirmwareState::AppCrashes(s) => s.on_exit(&mut req),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_cartridge(&mut self, cartridge: Cartridge) {
|
||||
self.state = FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge));
|
||||
self.state_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use prometeu_bytecode::assembler::assemble;
|
||||
use prometeu_bytecode::model::{BytecodeModule, FunctionMeta, SyscallDecl};
|
||||
use prometeu_drivers::hardware::Hardware;
|
||||
use prometeu_hal::cartridge::{AppMode, AssetsPayloadSource};
|
||||
use prometeu_hal::syscalls::caps;
|
||||
use prometeu_system::CrashReport;
|
||||
|
||||
fn invalid_game_cartridge() -> Cartridge {
|
||||
Cartridge {
|
||||
app_id: 7,
|
||||
title: "Broken Cart".into(),
|
||||
app_version: "1.0.0".into(),
|
||||
app_mode: AppMode::Game,
|
||||
capabilities: 0,
|
||||
program: vec![0, 0, 0, 0],
|
||||
assets: AssetsPayloadSource::empty(),
|
||||
asset_table: vec![],
|
||||
preload: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn trapping_game_cartridge() -> Cartridge {
|
||||
let code = assemble("PUSH_BOOL 1\nHOSTCALL 0\nHALT").expect("assemble");
|
||||
let program = BytecodeModule {
|
||||
version: 0,
|
||||
const_pool: vec![],
|
||||
functions: vec![FunctionMeta {
|
||||
code_offset: 0,
|
||||
code_len: code.len() as u32,
|
||||
..Default::default()
|
||||
}],
|
||||
code,
|
||||
debug_info: None,
|
||||
exports: vec![],
|
||||
syscalls: vec![SyscallDecl {
|
||||
module: "gfx".into(),
|
||||
name: "clear".into(),
|
||||
version: 1,
|
||||
arg_slots: 1,
|
||||
ret_slots: 0,
|
||||
}],
|
||||
}
|
||||
.serialize();
|
||||
|
||||
Cartridge {
|
||||
app_id: 8,
|
||||
title: "Trap Cart".into(),
|
||||
app_version: "1.0.0".into(),
|
||||
app_mode: AppMode::Game,
|
||||
capabilities: caps::GFX,
|
||||
program,
|
||||
assets: AssetsPayloadSource::empty(),
|
||||
asset_table: vec![],
|
||||
preload: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cartridge_transitions_to_app_crashes_when_vm_init_fails() {
|
||||
let mut firmware = Firmware::new(None);
|
||||
let mut hardware = Hardware::new();
|
||||
let signals = InputSignals::default();
|
||||
|
||||
firmware.load_cartridge(invalid_game_cartridge());
|
||||
firmware.tick(&signals, &mut hardware);
|
||||
|
||||
match &firmware.state {
|
||||
FirmwareState::AppCrashes(step) => match &step.report {
|
||||
CrashReport::VmInit { error } => {
|
||||
assert!(matches!(error, prometeu_vm::VmInitError::InvalidFormat));
|
||||
}
|
||||
other => panic!("expected VmInit crash report, got {:?}", other),
|
||||
},
|
||||
other => panic!("expected AppCrashes state, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_running_transitions_to_app_crashes_when_runtime_surfaces_trap() {
|
||||
let mut firmware = Firmware::new(None);
|
||||
let mut hardware = Hardware::new();
|
||||
let signals = InputSignals::default();
|
||||
|
||||
firmware.load_cartridge(trapping_game_cartridge());
|
||||
firmware.tick(&signals, &mut hardware);
|
||||
|
||||
assert!(matches!(firmware.state, FirmwareState::GameRunning(_)));
|
||||
|
||||
firmware.tick(&signals, &mut hardware);
|
||||
|
||||
match &firmware.state {
|
||||
FirmwareState::AppCrashes(step) => match &step.report {
|
||||
CrashReport::VmTrap { trap } => {
|
||||
assert!(trap.message.contains("Expected integer at index 0"));
|
||||
}
|
||||
other => panic!("expected VmTrap crash report, got {:?}", other),
|
||||
},
|
||||
other => panic!("expected AppCrashes state, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
pub use crate::firmware::firmware_step_crash_screen::AppCrashesStep;
|
||||
pub use crate::firmware::firmware_step_game_running::GameRunningStep;
|
||||
pub use crate::firmware::firmware_step_hub_home::HubHomeStep;
|
||||
pub use crate::firmware::firmware_step_launch_hub::LaunchHubStep;
|
||||
pub use crate::firmware::firmware_step_load_cartridge::LoadCartridgeStep;
|
||||
pub use crate::firmware::firmware_step_reset::ResetStep;
|
||||
pub use crate::firmware::firmware_step_splash_screen::SplashScreenStep;
|
||||
pub use crate::firmware::firmware_step_launch_hub::LaunchHubStep;
|
||||
pub use crate::firmware::firmware_step_hub_home::HubHomeStep;
|
||||
pub use crate::firmware::firmware_step_load_cartridge::LoadCartridgeStep;
|
||||
pub use crate::firmware::firmware_step_game_running::GameRunningStep;
|
||||
pub use crate::firmware::firmware_step_crash_screen::AppCrashesStep;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FirmwareState {
|
||||
@ -0,0 +1,67 @@
|
||||
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_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.hw.pad_mut().begin_frame(ctx.signals);
|
||||
|
||||
// Error screen: red background, white text
|
||||
ctx.hw.gfx_mut().clear(Color::RED);
|
||||
// For now we just log or show something simple
|
||||
// In the future, use draw_text
|
||||
ctx.hw.gfx_mut().present();
|
||||
|
||||
// If START is pressed, return to the Hub
|
||||
if ctx.hw.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"));
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameRunningStep;
|
||||
@ -17,7 +17,7 @@ impl GameRunningStep {
|
||||
ctx.hw.gfx_mut().present();
|
||||
}
|
||||
|
||||
result.map(|err| FirmwareState::AppCrashes(AppCrashesStep { error: err }))
|
||||
result.map(|report| FirmwareState::AppCrashes(AppCrashesStep { report }))
|
||||
}
|
||||
|
||||
pub fn on_exit(&mut self, _ctx: &mut PrometeuContext) {}
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::firmware::firmware_state::{AppCrashesStep, FirmwareState};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HubHomeStep;
|
||||
@ -17,7 +17,7 @@ impl HubHomeStep {
|
||||
ctx.hub.gui_update(ctx.os, ctx.hw);
|
||||
|
||||
if let Some(focused_id) = ctx.hub.window_manager.focused {
|
||||
if ctx.hw.pad().start.down {
|
||||
if ctx.hw.pad().start().down {
|
||||
ctx.hub.window_manager.remove_window(focused_id);
|
||||
} else {
|
||||
// System App runs here, drawing over the Hub background
|
||||
@ -30,8 +30,8 @@ impl HubHomeStep {
|
||||
|
||||
ctx.hw.gfx_mut().present();
|
||||
|
||||
if let Some(err) = error {
|
||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { error: err }));
|
||||
if let Some(report) = error {
|
||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||
}
|
||||
|
||||
None
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::firmware::boot_target::BootTarget;
|
||||
use crate::firmware::firmware_state::{FirmwareState, HubHomeStep, LoadCartridgeStep};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::model::CartridgeLoader;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::cartridge_loader::CartridgeLoader;
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchHubStep;
|
||||
@ -19,10 +19,15 @@ impl LaunchHubStep {
|
||||
Ok(cartridge) => {
|
||||
// In the case of debug, we could pause here, but the requirement says
|
||||
// for the Runtime to open the socket and wait.
|
||||
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep { cartridge }));
|
||||
return Some(FirmwareState::LoadCartridge(LoadCartridgeStep::new(cartridge)));
|
||||
}
|
||||
Err(e) => {
|
||||
ctx.os.log(LogLevel::Error, LogSource::Pos, 0, format!("Failed to auto-load cartridge: {:?}", e));
|
||||
ctx.os.log(
|
||||
LogLevel::Error,
|
||||
LogSource::Pos,
|
||||
0,
|
||||
format!("Failed to auto-load cartridge: {:?}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,52 @@
|
||||
use crate::firmware::firmware_state::{FirmwareState, GameRunningStep, HubHomeStep};
|
||||
use crate::firmware::firmware_state::{
|
||||
AppCrashesStep, FirmwareState, GameRunningStep, HubHomeStep,
|
||||
};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::model::{AppMode, Cartridge, Color, Rect};
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::cartridge::{AppMode, Cartridge};
|
||||
use prometeu_hal::color::Color;
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::window::Rect;
|
||||
use prometeu_system::CrashReport;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadCartridgeStep {
|
||||
pub cartridge: Cartridge,
|
||||
init_error: Option<CrashReport>,
|
||||
}
|
||||
|
||||
impl LoadCartridgeStep {
|
||||
pub fn new(cartridge: Cartridge) -> Self {
|
||||
Self { cartridge, init_error: None }
|
||||
}
|
||||
|
||||
pub fn on_enter(&mut self, ctx: &mut PrometeuContext) {
|
||||
ctx.os.log(LogLevel::Info, LogSource::Pos, 0, format!("Loading cartridge: {}", self.cartridge.title));
|
||||
ctx.os.log(
|
||||
LogLevel::Info,
|
||||
LogSource::Pos,
|
||||
0,
|
||||
format!("Loading cartridge: {}", self.cartridge.title),
|
||||
);
|
||||
|
||||
// Initialize Asset Manager
|
||||
ctx.hw.assets_mut().initialize_for_cartridge(
|
||||
self.cartridge.asset_table.clone(),
|
||||
self.cartridge.preload.clone(),
|
||||
self.cartridge.assets.clone()
|
||||
self.cartridge.assets.clone(),
|
||||
);
|
||||
|
||||
ctx.os.initialize_vm(ctx.vm, &self.cartridge);
|
||||
self.init_error = ctx.os.initialize_vm(ctx.vm, &self.cartridge).err();
|
||||
}
|
||||
|
||||
pub fn on_update(&mut self, ctx: &mut PrometeuContext) -> Option<FirmwareState> {
|
||||
if let Some(report) = self.init_error.take() {
|
||||
return Some(FirmwareState::AppCrashes(AppCrashesStep { report }));
|
||||
}
|
||||
|
||||
if self.cartridge.app_mode == AppMode::System {
|
||||
let id = ctx.hub.window_manager.add_window(
|
||||
self.cartridge.title.clone(),
|
||||
Rect { x: 40, y: 20, w: 240, h: 140 },
|
||||
Color::WHITE
|
||||
Color::WHITE,
|
||||
);
|
||||
ctx.hub.window_manager.set_focus(id);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::firmware::boot_target::BootTarget;
|
||||
use crate::firmware::firmware_state::{FirmwareState, LaunchHubStep, SplashScreenStep};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResetStep;
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::firmware::firmware_state::{FirmwareState, LaunchHubStep};
|
||||
use crate::firmware::prometeu_context::PrometeuContext;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
use crate::model::Color;
|
||||
use prometeu_hal::color::Color;
|
||||
use prometeu_hal::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplashScreenStep {
|
||||
@ -1,17 +1,18 @@
|
||||
mod boot_target;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod firmware;
|
||||
pub mod firmware_state;
|
||||
mod boot_target;
|
||||
|
||||
pub(crate) mod firmware_step_crash_screen;
|
||||
pub(crate) mod firmware_step_game_running;
|
||||
pub(crate) mod firmware_step_hub_home;
|
||||
pub(crate) mod firmware_step_launch_hub;
|
||||
pub(crate) mod firmware_step_load_cartridge;
|
||||
pub(crate) mod firmware_step_reset;
|
||||
pub(crate) mod firmware_step_splash_screen;
|
||||
pub(crate) mod firmware_step_launch_hub;
|
||||
pub(crate) mod firmware_step_hub_home;
|
||||
pub(crate) mod firmware_step_load_cartridge;
|
||||
pub(crate) mod firmware_step_game_running;
|
||||
pub(crate) mod firmware_step_crash_screen;
|
||||
mod prometeu_context;
|
||||
|
||||
pub use boot_target::BootTarget;
|
||||
pub use firmware::Firmware;
|
||||
pub use firmware_state::FirmwareState;
|
||||
pub use prometeu_context::PrometeuContext;
|
||||
pub use boot_target::BootTarget;
|
||||
@ -1,12 +1,11 @@
|
||||
use crate::firmware::boot_target::BootTarget;
|
||||
use crate::hardware::{HardwareBridge, InputSignals};
|
||||
use crate::prometeu_hub::PrometeuHub;
|
||||
use crate::prometeu_os::PrometeuOS;
|
||||
use crate::virtual_machine::VirtualMachine;
|
||||
use prometeu_hal::{HardwareBridge, InputSignals};
|
||||
use prometeu_system::{PrometeuHub, VirtualMachineRuntime};
|
||||
use prometeu_vm::VirtualMachine;
|
||||
|
||||
pub struct PrometeuContext<'a> {
|
||||
pub vm: &'a mut VirtualMachine,
|
||||
pub os: &'a mut PrometeuOS,
|
||||
pub os: &'a mut VirtualMachineRuntime,
|
||||
pub hub: &'a mut PrometeuHub,
|
||||
pub boot_target: &'a BootTarget,
|
||||
pub signals: &'a InputSignals,
|
||||
3
crates/console/prometeu-firmware/src/lib.rs
Normal file
3
crates/console/prometeu-firmware/src/lib.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod firmware;
|
||||
|
||||
pub use firmware::*;
|
||||
@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "prometeu-core"
|
||||
name = "prometeu-hal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
prometeu-bytecode = { path = "../prometeu-bytecode" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
prometeu-bytecode = { path = "../prometeu-bytecode" }
|
||||
163
crates/console/prometeu-hal/src/asset.rs
Normal file
163
crates/console/prometeu-hal/src/asset.rs
Normal file
@ -0,0 +1,163 @@
|
||||
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;
|
||||
pub const SCENE_LAYER_HEADER_BYTES_V1: usize = 28;
|
||||
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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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 }
|
||||
}
|
||||
}
|
||||
22
crates/console/prometeu-hal/src/asset_bridge.rs
Normal file
22
crates/console/prometeu-hal/src/asset_bridge.rs
Normal file
@ -0,0 +1,22 @@
|
||||
use crate::asset::{
|
||||
AssetEntry, AssetId, AssetLoadError, AssetOpStatus, BankTelemetry, 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 apply_commits(&self);
|
||||
fn bank_telemetry(&self) -> Vec<BankTelemetry>;
|
||||
fn slot_info(&self, slot: SlotRef) -> SlotStats;
|
||||
fn shutdown(&self);
|
||||
}
|
||||
52
crates/console/prometeu-hal/src/audio_bridge.rs
Normal file
52
crates/console/prometeu-hal/src/audio_bridge.rs
Normal file
@ -0,0 +1,52 @@
|
||||
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);
|
||||
}
|
||||
@ -15,18 +15,19 @@ pub enum ButtonId {
|
||||
Select = 11,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub struct Button {
|
||||
pub pressed: bool,
|
||||
pub released: bool,
|
||||
pub down: bool,
|
||||
pub hold_frames: u32,
|
||||
pub hold_frames: u16,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
pub fn begin_frame(&mut self, is_down_now: bool) {
|
||||
let was_down = self.down;
|
||||
self.down = is_down_now.clone();
|
||||
self.down = is_down_now;
|
||||
|
||||
self.pressed = !was_down && self.down;
|
||||
self.released = was_down && !self.down;
|
||||
312
crates/console/prometeu-hal/src/cartridge.rs
Normal file
312
crates/console/prometeu-hal/src/cartridge.rs
Normal file
@ -0,0 +1,312 @@
|
||||
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, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub enum AppMode {
|
||||
Game,
|
||||
System,
|
||||
}
|
||||
|
||||
#[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,
|
||||
Audio,
|
||||
Fs,
|
||||
Log,
|
||||
Asset,
|
||||
Bank,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(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>,
|
||||
}
|
||||
586
crates/console/prometeu-hal/src/cartridge_loader.rs
Normal file
586
crates/console/prometeu-hal/src/cartridge_loader.rs
Normal file
@ -0,0 +1,586 @@
|
||||
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::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 * 2),
|
||||
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 * 2),
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
/// Simple RGB565 color (0bRRRRRGGGGGGBBBBB).
|
||||
/// - 5 bits Red
|
||||
/// - 6 bits Green
|
||||
/// - 5 bits Blue
|
||||
/// Represents a 16-bit color in the RGB565 format.
|
||||
///
|
||||
/// There is no alpha channel.
|
||||
/// Transparency is handled via Color Key or Blend Mode.
|
||||
/// The RGB565 format is a common high-color representation for embedded systems:
|
||||
/// - **Red**: 5 bits (0..31)
|
||||
/// - **Green**: 6 bits (0..63)
|
||||
/// - **Blue**: 5 bits (0..31)
|
||||
///
|
||||
/// Prometeu does not have a hardware alpha channel. Transparency is achieved
|
||||
/// by using a specific color key (Magenta / 0xF81F) which the GFX engine
|
||||
/// skips during rendering.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Color(pub u16);
|
||||
|
||||
@ -69,5 +72,4 @@ impl Color {
|
||||
let hex = r8 << 16 | g8 << 8 | b8;
|
||||
hex as i32
|
||||
}
|
||||
|
||||
}
|
||||
10
crates/console/prometeu-hal/src/composer_status.rs
Normal file
10
crates/console/prometeu-hal/src/composer_status.rs
Normal file
@ -0,0 +1,10 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum ComposerOpStatus {
|
||||
Ok = 0,
|
||||
SceneUnavailable = 1,
|
||||
ArgRangeInvalid = 2,
|
||||
BankInvalid = 3,
|
||||
LayerInvalid = 4,
|
||||
SpriteOverflow = 5,
|
||||
}
|
||||
147
crates/console/prometeu-hal/src/debugger_protocol.rs
Normal file
147
crates/console/prometeu-hal/src/debugger_protocol.rs
Normal file
@ -0,0 +1,147 @@
|
||||
use crate::cartridge::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, 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"));
|
||||
}
|
||||
}
|
||||
80
crates/console/prometeu-hal/src/gfx_bridge.rs
Normal file
80
crates/console/prometeu-hal/src/gfx_bridge.rs
Normal file
@ -0,0 +1,80 @@
|
||||
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) -> &[u16];
|
||||
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);
|
||||
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;
|
||||
|
||||
fn scene_fade_level(&self) -> u8;
|
||||
fn set_scene_fade_level(&mut self, level: u8);
|
||||
fn scene_fade_color(&self) -> Color;
|
||||
fn set_scene_fade_color(&mut self, color: Color);
|
||||
fn hud_fade_level(&self) -> u8;
|
||||
fn set_hud_fade_level(&mut self, level: u8);
|
||||
fn hud_fade_color(&self) -> Color;
|
||||
fn set_hud_fade_color(&mut self, color: Color);
|
||||
}
|
||||
7
crates/console/prometeu-hal/src/glyph.rs
Normal file
7
crates/console/prometeu-hal/src/glyph.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Glyph {
|
||||
pub glyph_id: u16,
|
||||
pub palette_id: u8,
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
use crate::model::Color;
|
||||
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)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub enum TileSize {
|
||||
/// 8x8 pixels.
|
||||
Size8 = 8,
|
||||
@ -13,10 +17,12 @@ pub enum TileSize {
|
||||
|
||||
/// A container for graphical assets.
|
||||
///
|
||||
/// A TileBank stores both the raw pixel data (as palette indices) and the
|
||||
/// color palettes themselves. This encapsulates all the information needed
|
||||
/// to render a set of tiles.
|
||||
pub struct TileBank {
|
||||
/// 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.
|
||||
@ -24,22 +30,23 @@ pub struct TileBank {
|
||||
/// Height of the full bank sheet in pixels.
|
||||
pub height: usize,
|
||||
|
||||
/// Pixel data stored as 4-bit indices (packed into 8-bit values).
|
||||
/// Decoded pixel data stored as one palette index per pixel.
|
||||
/// Serialized payloads are packed; runtime memory is expanded.
|
||||
/// Index 0 is always reserved for transparency.
|
||||
pub pixel_indices: Vec<u8>,
|
||||
/// Table of 64 palettes, each containing 16 RGB565 colors, total of 1024 colors for a bank.
|
||||
pub palettes: [[Color; 16]; 64],
|
||||
/// Runtime-facing v1 palette table: 64 palettes of 16 RGB565 colors each.
|
||||
pub palettes: [[Color; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1],
|
||||
}
|
||||
|
||||
impl TileBank {
|
||||
/// Creates an empty tile bank with the specified dimensions.
|
||||
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], // Index 0 = Transparent
|
||||
palettes: [[Color::BLACK; 16]; 64],
|
||||
palettes: [[Color::BLACK; GLYPH_BANK_COLORS_PER_PALETTE]; GLYPH_BANK_PALETTE_COUNT_V1],
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +77,10 @@ impl TileBank {
|
||||
return Color::COLOR_KEY;
|
||||
}
|
||||
|
||||
self.palettes[palette_id as usize][pixel_index as usize]
|
||||
self.palettes
|
||||
.get(palette_id as usize)
|
||||
.and_then(|palette| palette.get(pixel_index as usize))
|
||||
.copied()
|
||||
.unwrap_or(Color::COLOR_KEY)
|
||||
}
|
||||
}
|
||||
31
crates/console/prometeu-hal/src/hardware_bridge.rs
Normal file
31
crates/console/prometeu-hal/src/hardware_bridge.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use crate::asset_bridge::AssetBridge;
|
||||
use crate::audio_bridge::AudioBridge;
|
||||
use crate::gfx_bridge::GfxBridge;
|
||||
use crate::pad_bridge::PadBridge;
|
||||
use crate::sprite::Sprite;
|
||||
use crate::touch_bridge::TouchBridge;
|
||||
|
||||
pub trait HardwareBridge {
|
||||
fn begin_frame(&mut self);
|
||||
fn bind_scene(&mut self, scene_bank_id: usize) -> bool;
|
||||
fn unbind_scene(&mut self);
|
||||
fn set_camera(&mut self, x: i32, y: i32);
|
||||
fn emit_sprite(&mut self, sprite: Sprite) -> bool;
|
||||
fn render_frame(&mut self);
|
||||
fn has_glyph_bank(&self, bank_id: usize) -> bool;
|
||||
|
||||
fn gfx(&self) -> &dyn GfxBridge;
|
||||
fn gfx_mut(&mut self) -> &mut dyn GfxBridge;
|
||||
|
||||
fn audio(&self) -> &dyn AudioBridge;
|
||||
fn audio_mut(&mut self) -> &mut dyn AudioBridge;
|
||||
|
||||
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;
|
||||
|
||||
fn assets(&self) -> &dyn AssetBridge;
|
||||
fn assets_mut(&mut self) -> &mut dyn AssetBridge;
|
||||
}
|
||||
24
crates/console/prometeu-hal/src/host_context.rs
Normal file
24
crates/console/prometeu-hal/src/host_context.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use crate::hardware_bridge::HardwareBridge;
|
||||
use crate::vm_fault::VmFault;
|
||||
|
||||
pub struct HostContext<'a> {
|
||||
pub hw: Option<&'a mut dyn HardwareBridge>,
|
||||
}
|
||||
|
||||
impl<'a> HostContext<'a> {
|
||||
pub fn new(hw: Option<&'a mut dyn HardwareBridge>) -> Self {
|
||||
Self { hw }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn require_hw(&mut self) -> Result<&mut dyn HardwareBridge, VmFault> {
|
||||
match &mut self.hw {
|
||||
Some(hw) => Ok(*hw),
|
||||
None => Err(VmFault::Unavailable),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HostContextProvider {
|
||||
fn make_ctx(&'_ mut self) -> HostContext<'_>;
|
||||
}
|
||||
27
crates/console/prometeu-hal/src/host_return.rs
Normal file
27
crates/console/prometeu-hal/src/host_return.rs
Normal file
@ -0,0 +1,27 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,3 @@ pub struct InputSignals {
|
||||
pub x_pos: i32,
|
||||
pub y_pos: i32,
|
||||
}
|
||||
|
||||
impl InputSignals {
|
||||
}
|
||||
47
crates/console/prometeu-hal/src/lib.rs
Normal file
47
crates/console/prometeu-hal/src/lib.rs
Normal file
@ -0,0 +1,47 @@
|
||||
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 hardware_bridge;
|
||||
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 pad_bridge;
|
||||
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 mod window;
|
||||
|
||||
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 hardware_bridge::HardwareBridge;
|
||||
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 pad_bridge::PadBridge;
|
||||
pub use touch_bridge::TouchBridge;
|
||||
@ -1,5 +1,4 @@
|
||||
use crate::log::LogLevel;
|
||||
use crate::log::LogSource;
|
||||
use crate::log::{LogLevel, LogSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEvent {
|
||||
@ -1,10 +1,13 @@
|
||||
use std::collections::VecDeque;
|
||||
use crate::log::{LogEvent, LogLevel, LogSource};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
pub struct LogService {
|
||||
events: VecDeque<LogEvent>,
|
||||
capacity: usize,
|
||||
next_seq: u64,
|
||||
pub logs_count: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl LogService {
|
||||
@ -13,10 +16,19 @@ impl LogService {
|
||||
events: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
next_seq: 0,
|
||||
logs_count: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log(&mut self, ts_ms: u64, frame: u64, level: LogLevel, source: LogSource, tag: u16, msg: String) {
|
||||
pub fn log(
|
||||
&mut self,
|
||||
ts_ms: u64,
|
||||
frame: u64,
|
||||
level: LogLevel,
|
||||
source: LogSource,
|
||||
tag: u16,
|
||||
msg: String,
|
||||
) {
|
||||
if self.events.len() >= self.capacity {
|
||||
self.events.pop_front();
|
||||
}
|
||||
@ -30,6 +42,11 @@ impl LogService {
|
||||
msg,
|
||||
});
|
||||
self.next_seq += 1;
|
||||
self.logs_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn reset_count(&mut self) {
|
||||
self.logs_count.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get_recent(&self, n: usize) -> Vec<LogEvent> {
|
||||
@ -1,9 +1,9 @@
|
||||
mod log_level;
|
||||
mod log_source;
|
||||
mod log_event;
|
||||
mod log_level;
|
||||
mod log_service;
|
||||
mod log_source;
|
||||
|
||||
pub use log_level::LogLevel;
|
||||
pub use log_source::LogSource;
|
||||
pub use log_event::LogEvent;
|
||||
pub use log_level::LogLevel;
|
||||
pub use log_service::LogService;
|
||||
pub use log_source::LogSource;
|
||||
17
crates/console/prometeu-hal/src/native_helpers.rs
Normal file
17
crates/console/prometeu-hal/src/native_helpers.rs
Normal file
@ -0,0 +1,17 @@
|
||||
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)))
|
||||
}
|
||||
21
crates/console/prometeu-hal/src/native_interface.rs
Normal file
21
crates/console/prometeu-hal/src/native_interface.rs
Normal file
@ -0,0 +1,21 @@
|
||||
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>;
|
||||
}
|
||||
20
crates/console/prometeu-hal/src/pad_bridge.rs
Normal file
20
crates/console/prometeu-hal/src/pad_bridge.rs
Normal file
@ -0,0 +1,20 @@
|
||||
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;
|
||||
}
|
||||
@ -7,12 +7,7 @@ pub struct Sample {
|
||||
|
||||
impl Sample {
|
||||
pub fn new(sample_rate: u32, data: Vec<i16>) -> Self {
|
||||
Self {
|
||||
sample_rate,
|
||||
data,
|
||||
loop_start: None,
|
||||
loop_end: None,
|
||||
}
|
||||
Self { sample_rate, data, loop_start: None, loop_end: None }
|
||||
}
|
||||
|
||||
pub fn with_loop(mut self, start: u32, end: u32) -> Self {
|
||||
50
crates/console/prometeu-hal/src/scene_bank.rs
Normal file
50
crates/console/prometeu-hal/src/scene_bank.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use crate::scene_layer::SceneLayer;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneBank {
|
||||
pub layers: [SceneLayer; 4],
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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_bank_id: u8, parallax_x: f32, parallax_y: f32, glyph_id: u16) -> SceneLayer {
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_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: glyph_bank_id },
|
||||
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);
|
||||
}
|
||||
}
|
||||
59
crates/console/prometeu-hal/src/scene_layer.rs
Normal file
59
crates/console/prometeu-hal/src/scene_layer.rs
Normal file
@ -0,0 +1,59 @@
|
||||
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_bank_id: u8,
|
||||
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_bank_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_bank_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);
|
||||
}
|
||||
}
|
||||
565
crates/console/prometeu-hal/src/scene_viewport_cache.rs
Normal file
565
crates/console/prometeu-hal/src/scene_viewport_cache.rs
Normal file
@ -0,0 +1,565 @@
|
||||
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_bank_id: u8,
|
||||
}
|
||||
|
||||
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_bank_id: layer.glyph_bank_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_bank_id: u8,
|
||||
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_bank_id: layer.glyph_bank_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_bank_id = layer.glyph_bank_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::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_bank_id: u8, 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,
|
||||
glyph_bank_id,
|
||||
x % 2 == 0,
|
||||
y % 2 == 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
SceneLayer {
|
||||
active: true,
|
||||
glyph_bank_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_bank_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);
|
||||
}
|
||||
}
|
||||
536
crates/console/prometeu-hal/src/scene_viewport_resolver.rs
Normal file
536
crates/console/prometeu-hal/src/scene_viewport_resolver.rs
Normal file
@ -0,0 +1,536 @@
|
||||
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::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_bank_id: 1,
|
||||
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(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let update = resolver.update(&scene, 0, 0);
|
||||
|
||||
assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 });
|
||||
assert_eq!(update.layer_anchors[0], TileAnchor { x: 12, y: 8 });
|
||||
assert_eq!(update.layer_anchors[1], TileAnchor { x: 12, y: 8 });
|
||||
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(320, 180, 25, 16, 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(320, 180, 25, 16, 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: 12, y: 8 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_high_speed_movement_advances_in_tile_steps() {
|
||||
let scene = make_scene();
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let _ = resolver.update(&scene, 0, 0);
|
||||
let update = resolver.update(&scene, 64, 0);
|
||||
|
||||
assert!(update.master_anchor.x > 12);
|
||||
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(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let update = resolver.update(&scene, 10_000, 10_000);
|
||||
|
||||
assert_eq!(update.master_anchor, TileAnchor { x: 51, y: 56 });
|
||||
assert_eq!(update.layer_anchors[0], TileAnchor { x: 51, y: 56 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_trigger_converts_to_non_overlapping_region_requests() {
|
||||
let scene = make_scene();
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 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, 25);
|
||||
assert_eq!(regions[0].height, 16);
|
||||
assert_eq!(regions[1].width, 24);
|
||||
assert_eq!(regions[1].height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_scene_requests_full_invalidation_for_all_layers() {
|
||||
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
|
||||
|
||||
let requests = resolver.reset_scene();
|
||||
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| matches!(request, CacheRefreshRequest::InvalidateLayer { .. }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::model::Sample;
|
||||
use crate::sample::Sample;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A container for audio assets.
|
||||
@ -1,10 +1,11 @@
|
||||
use crate::model::Tile;
|
||||
use crate::glyph::Glyph;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Sprite {
|
||||
pub tile: Tile,
|
||||
pub glyph: Glyph,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub layer: u8,
|
||||
pub bank_id: u8,
|
||||
pub active: bool,
|
||||
pub flip_x: bool,
|
||||
160
crates/console/prometeu-hal/src/syscalls.rs
Normal file
160
crates/console/prometeu-hal/src/syscalls.rs
Normal file
@ -0,0 +1,160 @@
|
||||
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**: Graphics (GFX)
|
||||
/// - **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,
|
||||
SystemRunCart = 0x0002,
|
||||
GfxClear = 0x1001,
|
||||
GfxFillRect = 0x1002,
|
||||
GfxDrawLine = 0x1003,
|
||||
GfxDrawCircle = 0x1004,
|
||||
GfxDrawDisc = 0x1005,
|
||||
GfxDrawSquare = 0x1006,
|
||||
GfxDrawText = 0x1008,
|
||||
GfxClear565 = 0x1010,
|
||||
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,
|
||||
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)
|
||||
}
|
||||
11
crates/console/prometeu-hal/src/syscalls/caps.rs
Normal file
11
crates/console/prometeu-hal/src/syscalls/caps.rs
Normal file
@ -0,0 +1,11 @@
|
||||
use super::CapFlags;
|
||||
|
||||
pub const NONE: CapFlags = 0;
|
||||
pub const SYSTEM: CapFlags = 1 << 0;
|
||||
pub const GFX: 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 ALL: CapFlags = SYSTEM | GFX | AUDIO | FS | LOG | ASSET | BANK;
|
||||
27
crates/console/prometeu-hal/src/syscalls/domains/asset.rs
Normal file
27
crates/console/prometeu-hal/src/syscalls/domains/asset.rs
Normal file
@ -0,0 +1,27 @@
|
||||
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),
|
||||
];
|
||||
14
crates/console/prometeu-hal/src/syscalls/domains/audio.rs
Normal file
14
crates/console/prometeu-hal/src/syscalls/domains/audio.rs
Normal file
@ -0,0 +1,14 @@
|
||||
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),
|
||||
];
|
||||
7
crates/console/prometeu-hal/src/syscalls/domains/bank.rs
Normal file
7
crates/console/prometeu-hal/src/syscalls/domains/bank.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
|
||||
|
||||
pub(crate) const ENTRIES: &[SyscallRegistryEntry] =
|
||||
&[SyscallRegistryEntry::builder(Syscall::BankInfo, "bank", "info")
|
||||
.args(1)
|
||||
.rets(2)
|
||||
.caps(caps::BANK)];
|
||||
22
crates/console/prometeu-hal/src/syscalls/domains/composer.rs
Normal file
22
crates/console/prometeu-hal/src/syscalls/domains/composer.rs
Normal file
@ -0,0 +1,22 @@
|
||||
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
|
||||
|
||||
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
|
||||
SyscallRegistryEntry::builder(Syscall::ComposerBindScene, "composer", "bind_scene")
|
||||
.args(1)
|
||||
.rets(1)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
SyscallRegistryEntry::builder(Syscall::ComposerUnbindScene, "composer", "unbind_scene")
|
||||
.rets(1)
|
||||
.caps(caps::GFX)
|
||||
.cost(2),
|
||||
SyscallRegistryEntry::builder(Syscall::ComposerSetCamera, "composer", "set_camera")
|
||||
.args(2)
|
||||
.caps(caps::GFX)
|
||||
.cost(2),
|
||||
SyscallRegistryEntry::builder(Syscall::ComposerEmitSprite, "composer", "emit_sprite")
|
||||
.args(9)
|
||||
.rets(1)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
];
|
||||
68
crates/console/prometeu-hal/src/syscalls/domains/fs.rs
Normal file
68
crates/console/prometeu-hal/src/syscalls/domains/fs.rs
Normal file
@ -0,0 +1,68 @@
|
||||
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),
|
||||
];
|
||||
36
crates/console/prometeu-hal/src/syscalls/domains/gfx.rs
Normal file
36
crates/console/prometeu-hal/src/syscalls/domains/gfx.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
|
||||
|
||||
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
|
||||
SyscallRegistryEntry::builder(Syscall::GfxClear, "gfx", "clear")
|
||||
.args(1)
|
||||
.caps(caps::GFX)
|
||||
.cost(20),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxFillRect, "gfx", "fill_rect")
|
||||
.args(5)
|
||||
.caps(caps::GFX)
|
||||
.cost(20),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxDrawLine, "gfx", "draw_line")
|
||||
.args(5)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxDrawCircle, "gfx", "draw_circle")
|
||||
.args(4)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxDrawDisc, "gfx", "draw_disc")
|
||||
.args(5)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxDrawSquare, "gfx", "draw_square")
|
||||
.args(6)
|
||||
.caps(caps::GFX)
|
||||
.cost(5),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxDrawText, "gfx", "draw_text")
|
||||
.args(4)
|
||||
.caps(caps::GFX)
|
||||
.cost(20),
|
||||
SyscallRegistryEntry::builder(Syscall::GfxClear565, "gfx", "clear_565")
|
||||
.args(1)
|
||||
.caps(caps::GFX)
|
||||
.cost(20),
|
||||
];
|
||||
14
crates/console/prometeu-hal/src/syscalls/domains/log.rs
Normal file
14
crates/console/prometeu-hal/src/syscalls/domains/log.rs
Normal file
@ -0,0 +1,14 @@
|
||||
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),
|
||||
];
|
||||
22
crates/console/prometeu-hal/src/syscalls/domains/mod.rs
Normal file
22
crates/console/prometeu-hal/src/syscalls/domains/mod.rs
Normal file
@ -0,0 +1,22 @@
|
||||
mod asset;
|
||||
mod audio;
|
||||
mod bank;
|
||||
mod composer;
|
||||
mod fs;
|
||||
mod gfx;
|
||||
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(composer::ENTRIES.iter())
|
||||
.chain(audio::ENTRIES.iter())
|
||||
.chain(fs::ENTRIES.iter())
|
||||
.chain(log::ENTRIES.iter())
|
||||
.chain(asset::ENTRIES.iter())
|
||||
.chain(bank::ENTRIES.iter())
|
||||
}
|
||||
11
crates/console/prometeu-hal/src/syscalls/domains/system.rs
Normal file
11
crates/console/prometeu-hal/src/syscalls/domains/system.rs
Normal file
@ -0,0 +1,11 @@
|
||||
use crate::syscalls::{Syscall, SyscallRegistryEntry, caps};
|
||||
|
||||
pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
|
||||
SyscallRegistryEntry::builder(Syscall::SystemHasCart, "system", "has_cart")
|
||||
.rets(1)
|
||||
.caps(caps::SYSTEM),
|
||||
SyscallRegistryEntry::builder(Syscall::SystemRunCart, "system", "run_cart")
|
||||
.caps(caps::SYSTEM)
|
||||
.non_deterministic()
|
||||
.cost(50),
|
||||
];
|
||||
103
crates/console/prometeu-hal/src/syscalls/registry.rs
Normal file
103
crates/console/prometeu-hal/src/syscalls/registry.rs
Normal file
@ -0,0 +1,103 @@
|
||||
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),
|
||||
0x0002 => Some(Self::SystemRunCart),
|
||||
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),
|
||||
0x1010 => Some(Self::GfxClear565),
|
||||
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),
|
||||
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::SystemRunCart => "SystemRunCart",
|
||||
Self::GfxClear => "GfxClear",
|
||||
Self::GfxFillRect => "GfxFillRect",
|
||||
Self::GfxDrawLine => "GfxDrawLine",
|
||||
Self::GfxDrawCircle => "GfxDrawCircle",
|
||||
Self::GfxDrawDisc => "GfxDrawDisc",
|
||||
Self::GfxDrawSquare => "GfxDrawSquare",
|
||||
Self::GfxDrawText => "GfxDrawText",
|
||||
Self::GfxClear565 => "GfxClear565",
|
||||
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::BankInfo => "BankInfo",
|
||||
}
|
||||
}
|
||||
}
|
||||
156
crates/console/prometeu-hal/src/syscalls/resolver.rs
Normal file
156
crates/console/prometeu-hal/src/syscalls/resolver.rs
Normal file
@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
432
crates/console/prometeu-hal/src/syscalls/tests.rs
Normal file
432
crates/console/prometeu-hal/src/syscalls/tests.rs
Normal file
@ -0,0 +1,432 @@
|
||||
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("gfx", "clear", 1).expect("known identity must resolve");
|
||||
assert_eq!(id.id, 0x1001);
|
||||
assert_eq!(id.meta.module, "gfx");
|
||||
assert_eq!(id.meta.name, "clear");
|
||||
assert_eq!(id.meta.version, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_rejects_unknown_identity() {
|
||||
let res = resolve_syscall("gfx", "nonexistent", 1);
|
||||
assert!(res.is_none());
|
||||
|
||||
let requested = [SyscallIdentity { module: "gfx", name: "nonexistent", version: 1 }];
|
||||
let err = resolve_program_syscalls(&requested, 0).unwrap_err();
|
||||
match err {
|
||||
LoadError::UnknownSyscall { module, name, version } => {
|
||||
assert_eq!(module, "gfx");
|
||||
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("gfx", "set_sprite", 1).is_none());
|
||||
|
||||
let requested = [SyscallIdentity { module: "gfx", name: "set_sprite", version: 1 }];
|
||||
let err = resolve_program_syscalls(&requested, caps::ALL).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
LoadError::UnknownSyscall { module: "gfx".into(), name: "set_sprite".into(), version: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_enforces_capabilities() {
|
||||
let requested = [SyscallIdentity { module: "gfx", 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, "gfx");
|
||||
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::GFX).expect("must resolve with caps");
|
||||
assert_eq!(ok.len(), 1);
|
||||
assert_eq!(ok[0].id, 0x1001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_resolver_returns_expected_id_for_known_identity() {
|
||||
let declared = [prometeu_bytecode::SyscallDecl {
|
||||
module: "gfx".into(),
|
||||
name: "clear".into(),
|
||||
version: 1,
|
||||
arg_slots: 1,
|
||||
ret_slots: 0,
|
||||
}];
|
||||
|
||||
let ok =
|
||||
resolve_declared_program_syscalls(&declared, caps::GFX).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: "gfx".into(),
|
||||
name: "nonexistent".into(),
|
||||
version: 1,
|
||||
arg_slots: 1,
|
||||
ret_slots: 0,
|
||||
}];
|
||||
|
||||
let err = resolve_declared_program_syscalls(&declared, caps::GFX).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
DeclaredLoadError::UnknownSyscall {
|
||||
module: "gfx".into(),
|
||||
name: "nonexistent".into(),
|
||||
version: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_resolver_rejects_missing_capability() {
|
||||
let declared = [prometeu_bytecode::SyscallDecl {
|
||||
module: "gfx".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::GFX,
|
||||
provided: caps::NONE,
|
||||
module: "gfx".into(),
|
||||
name: "clear".into(),
|
||||
version: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_resolver_rejects_abi_mismatch() {
|
||||
let declared = [prometeu_bytecode::SyscallDecl {
|
||||
module: "gfx".into(),
|
||||
name: "draw_line".into(),
|
||||
version: 1,
|
||||
arg_slots: 4,
|
||||
ret_slots: 0,
|
||||
}];
|
||||
|
||||
let err = resolve_declared_program_syscalls(&declared, caps::GFX).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
DeclaredLoadError::AbiMismatch {
|
||||
module: "gfx".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 clear_565 = meta_for(Syscall::GfxClear565);
|
||||
assert_eq!(clear_565.arg_slots, 1);
|
||||
assert_eq!(clear_565.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 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".into(), name: "slot_info".into(), 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::GFX | 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);
|
||||
}
|
||||
316
crates/console/prometeu-hal/src/telemetry.rs
Normal file
316
crates/console/prometeu-hal/src/telemetry.rs
Normal file
@ -0,0 +1,316 @@
|
||||
use crate::log::{LogLevel, LogService, LogSource};
|
||||
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,
|
||||
|
||||
// Log Pressure from the last completed logical frame
|
||||
pub logs_count: u32,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
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),
|
||||
logs_count: self.logs_count.load(Ordering::Relaxed),
|
||||
vm_steps: self.vm_steps.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_steps.store(0, Ordering::Relaxed);
|
||||
self.logs_count.store(0, Ordering::Relaxed);
|
||||
self.current_logs_count.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 mut tel = TelemetryFrame::default();
|
||||
tel.cycles_used = 150;
|
||||
tel.syscalls = 10;
|
||||
tel.host_cpu_time_us = 500;
|
||||
tel.glyph_slots_used = 2;
|
||||
|
||||
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(¤t));
|
||||
tel.logs_count.store(3, Ordering::Relaxed);
|
||||
|
||||
let snapshot = tel.snapshot();
|
||||
|
||||
assert_eq!(snapshot.logs_count, 3);
|
||||
assert_eq!(current.load(Ordering::Relaxed), 7);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user