25 Commits
Author SHA1 Message Date
ddidderr c4fb345197 chore(release): prepare v1.2.0
Bump the package version to 1.2.0 for the high-score demo database seeding, service hardening, web retry bounds, and multiball parity and divergence fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-31 20:48:59 +02:00
ddidderr 4385a661b3 web 2026-08-31 20:46:20 +02:00
ddidderr a6967b4d1b fix(divergence): reject occupied multiball wheel slots
The original leaves contact owner 2 after either ball fills a wheel hole during
multiball. Once play collapses to one ball, that sentinel permits one more
capture and award in the visibly occupied hole before becoming permanent.

Deliberately replace the completed wheel contact with the permanent 99 sentinel
for both ball slots. This keeps visual occupancy, collision admission, and
scoring consistent: a filled hole cannot consume or reward another ball. The
special reserve hole and claw retain their original contact behavior.

Document `divergence` as the required Conventional Commit scope for future fixes
that intentionally differ from original-game behavior.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:11 +02:00
ddidderr a394df23e7 fix(multiball): match capture collapse timing
Capture removal and ordinary drains do not clear the original game's shared
multiball flag at the same point. The clone treated both transitions alike,
which could end double scoring before ball two's remaining slot pass or leave
it active after ball two entered a capture hole. It also failed to pause the
returning claw until collapse and to clear the flag on a fifth lock.

Collapse capture-driven multiball state at the next timer callback, retain the
immediate drain behavior, and end the mode explicitly when all five lock
contacts complete. Calculate lock awards from the live contact words so a
second ball still settling in another hole is counted. Stage effect-seven slot
creation until the primary substep batch returns, matching the timer's spawn
request ordering.

The original leaves a multiball capture contact as value 2, so a later single
ball may enter that same wheel slot once more and convert it to the permanent
99 sentinel. This possibly unintended original-game quirk remains for binary
parity.

Test Plan:
- `just test` -- passed (141 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:10 +02:00
ddidderr d5336a2a92 build: production profile for highscore-server 2026-08-29 20:50:41 +02:00
ddidderr 8e1a1c91e2 feat(highscores): seed new server databases with demo scores
New SQLite high-score databases were previously created empty, so the first
shared table had no original entries. Seed only database paths that did not
exist before opening with the ten distributed demo scores, while leaving
existing files and in-memory stores unchanged. Add coverage for both first
creation and existing empty files, and document the behavior.

Test Plan:
- `just test-highscore-server` -- passed (8 tests)
- `just clippy-highscore-server` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:44:05 +02:00
ddidderr 9023af7e7e fix(web): define high-score retry bounds
The browser storage plugin initializes and updates its high-score retry
backoff, but the constants supplying its initial and maximum delays were
lost during the merge that combined the retry and relative-endpoint fixes.
Define the intended 250 ms initial delay and 30 s cap so the plugin can load
and retain bounded retry behavior after transient submission failures.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- Node VM top-level load harness -- passed
- `prettier --check tdkpin-rs/web/storage.js` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `just test` -- passed (144 tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:18:53 +02:00
ddidderr 12d1ec0aab Merge branch 'tomerge' 2026-08-29 20:11:13 +02:00
ddidderr 9c5b033c0a fix(web): use relative path for high-score API endpoint
Change the high-score endpoint URL in storage.js from "/api/highscores" to
"./api/highscores". When serving the web build from a subpath rather than the
domain root, an absolute path sends fetch requests to the domain root instead
of the nested application path. Using a relative URL ensures requests resolve
relative to the active document path while still working when hosted at root.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- `npx prettier --check tdkpin-rs/web/storage.js` -- passed
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:08:15 +02:00
ddidderr bc1ebcaaaa fix(web): back off high-score retries
The browser previously retried every failed shared high-score submission on the
50 ms polling interval. With server-side rate limiting and database load
shedding, immediate 429 and 503 responses could create a tight retry loop.
Add a bounded exponential delay with jitter, honor Retry-After on transient
responses, and reset the delay after a successful submission. Keep the pending
revision retryable without allowing overlapping requests.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- `prettier --check tdkpin-rs/web/storage.js` -- passed
- Node VM retry timing harness covering Retry-After, backoff, and reset -- passed
- `git diff --cached --check` -- passed
2026-08-29 19:49:49 +02:00
ddidderr 3dff722535 fix(highscores): bound service resource usage
The high-score endpoints previously accepted unbounded request bodies and ran
SQLite work directly in async handlers, allowing oversized input or database
contention to consume server resources. Add a 1 KiB route body limit, admit
only one database operation at a time, shed excess requests with a clear 503,
and run accepted SQLite work on blocking threads while retaining admission
until that work finishes. Extend the Nginx example with matching request,
connection, body, and proxy time limits, and cover the limits, health
availability, contention, and cancellation behavior with tests.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (144 tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/highscore-server/Cargo.toml -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 19:36:53 +02:00
ddidderr 7ee2e71bc7 fix: relative api/highscores path 2026-08-29 18:46:10 +02:00
ddidderr 86434aaa2b fix: remote unused github workflow 2026-08-29 18:07:54 +02:00
ddidderr 51502ef92f chore(release): prepare v1.1.0
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Bump the package version to 1.1.0 for the web port, optional shared high-score service, and post-1.0 parity and edge fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-29 17:52:53 +02:00
ddidderr f9063bd2ff chore(web): regenerate browser artifact
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 17:50:49 +02:00
ddidderr a36856e526 fix(game): allow launcher input while tilted
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Preserve the original launcher path when a nudge tilts a game before launch.
The Win16 press and release handlers process scan code 0x50 without consulting
`game_tilted`, so Tilt disables flippers and scoring but does not strand a ball
in the shooter lane. Remove the extra Rust gate, retain the original launch
sound suppression during Tilt, and add regression coverage for the waiting-ball
case.

Test Plan:
- `just test` -- passed (138 Rust tests and 3 highscore-server tests)
- `just clippy` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `git diff --check` and `git diff --cached --check` -- passed
2026-08-29 17:46:24 +02:00
ddidderr acb0c20b59 fix(web): vendor the Macroquad browser loader
The page depended on the externally hosted miniquad bundle, which violates a
same-origin `script-src 'self'` policy and makes the game depend on a third
party at runtime. Vendor the current official loader alongside the web assets
and move the WASM `load` call into a local bootstrap script, preserving plugin
registration order without inline JavaScript.

Document the CSP requirement for WebAssembly compilation and same-origin
connections. The vendored bundle is the current response from the official
Macroquad loader URL and was syntax-checked before committing.

Test Plan:
- `just web-build` -- passed
- `node --check web/mq_js_bundle.js web/storage.js web/bootstrap.js` -- passed
- `prettier --check web/storage.js web/bootstrap.js` -- passed
- Browser smoke test with `script-src 'self' 'wasm-unsafe-eval'` and `connect-src 'self'` -- passed; 640x460 canvas and no CSP/script errors
- `git diff --cached --check` -- passed
2026-08-29 17:12:43 +02:00
ddidderr a81741b470 chore(highscores): align dependency and lint standards
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Update the high-score service to the latest compatible direct releases while
keeping the requested Cargo.toml ranges: 0.x crates use their latest minor
line and 1.x crates use their major-only range. Refresh the standalone lockfile
so it resolves axum 0.8.9, rusqlite 0.40.2, serde 1.0.229, serde_json 1.0.151,
Tokio 1.53.1, and the latest dev-tool releases.

Copy the main package's Clippy policy and make the repository's formatting
recipes cover the service. The nested crate inherits the parent rustfmt.toml,
so one configuration remains authoritative; newly required documentation and
borrow style also keep the stricter pedantic policy clean.

Test Plan:
- `cargo update --manifest-path highscore-server/Cargo.toml` -- passed
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed with the shared lint policy
- `just fmt-highscore-server` and `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `cargo tree --manifest-path highscore-server/Cargo.toml --depth 1` -- confirmed latest direct resolutions
- `rustfmt +nightly --print-config current` -- confirmed the parent rustfmt settings
- `rumdl check --flavor commonmark highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 16:03:11 +02:00
ddidderr 3b7b84affe chore(highscores): ignore the local SQLite database
The service defaults to a database file in the Rust project directory when no
production path is configured. Ignore that file and SQLite sidecars so local
runs do not create accidental repository changes.

Test Plan:
- `git diff --cached --check` -- passed
- Ignore patterns verified against the default database filename
2026-08-29 15:55:41 +02:00
ddidderr 5dd7f38450 feat(web): use the shared high-score service
Keep browser settings and the existing local save fallback, but route the
browser high-score table through the same-origin service when it is available.
The WASM storage bridge now accepts fetched score JSON and queues one validated
submission at a time. The JavaScript plugin fetches the canonical table,
submits accepted name-entry scores, ignores stale responses, retries failures,
and feeds successful responses back into the running game.

Update the web and project documentation to describe the optional shared
leaderboard and regenerate the tracked browser artifact. A missing service
continues to leave the local table usable; the service documentation records
that anonymous client scores are intentionally not tamper-resistant.

Test Plan:
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt -- --check` -- passed
- `node --check web/storage.js` and `prettier --check web/storage.js` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md README.md web/README.md highscore-server/README.md` -- passed
- Browser WASM load through same-origin API proxy -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:55:15 +02:00
ddidderr ec0fdfd6b4 feat(highscores): add SQLite Axum service
Add a small standalone Axum service for the shared anonymous top-ten table.
SQLite keeps the deployment self-contained, while one transaction inserts a
validated name and score and removes entries below the canonical top ten.
Expose a health endpoint and same-origin API, with an nginx proxy block and
run instructions beside the service. Keep the service outside the game crate
so native gameplay persistence remains unchanged.

Test Plan:
- `cargo test --manifest-path highscore-server/Cargo.toml` -- passed (3 tests)
- `cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- Live executable health, POST, and GET smoke test -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:50:04 +02:00
ddidderr 8fe9431989 fix(game): restore startup and multiball edge behavior
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The interactive build inherited Macroquad's fixed zero random state, making
maximum-power launcher shots repeat across fresh runs. Seed the platform
generator from the startup clock before taking the program-global gameplay
seed. Require record 148's still-live contact before the effect-seven special
respawn and clear active multiball state on an ordinary ball drain, so a
released reserve ball cannot be recreated after both slots are lost.

Type-4 target and effect handlers now honor each record's predicted broadphase
before changing its entry latch. This preserves the latch while another
multiball slot is elsewhere, preventing repeated upper-left corridor scoring.
The regressions and regenerated browser artifact stay with the implementation.

Test Plan:
- `just test` -- passed (137 tests)
- `just clippy` -- passed
- `cargo build --profile production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:27:28 +02:00
ddidderr a21f01c02d fix(web): keep game at original canvas size
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The browser shell previously sized the canvas to the full viewport, causing
Macroquad to scale the 640x460 game presentation as the website changed size.
Keep the canvas at the original 640x460 CSS-pixel dimensions and center it on
a black page instead. A minimum page size and scrolling preserve access to the
fixed canvas on viewports smaller than the original presentation.

Test Plan:
- `just web-build` -- passed
- `git diff --check` -- passed
- Browser smoke test -- canvas measured 640x460 and was centered at (320,130)
  in a 1280x720 viewport; the rendered game remained at the intended fixed
  presentation size with no runtime errors
2026-08-29 14:49:37 +02:00
ddidderr b079cfa196 feat(web): add browser build for TDK Pinball
Expose the existing Macroquad game as a static WASM website while preserving
native desktop behavior. Native-only simulation/file-export code and the
per-user filesystem save path are now separated from the browser build.

The browser version uses a small WASM-only storage support crate and a
Macroquad-compatible JavaScript plugin to persist the same JSON settings and
high scores in localStorage. Browser audio decoding starts in an owned
background coroutine so the game can render its original loading/attract
screens while the embedded sounds finish loading. The checked-in web bundle
contains the optimized WASM, centered black HTML shell, and build/serve
instructions.

Test Plan:
- `just test` -- passed, 135 tests
- `just clippy` -- passed
- `cargo clippy --target wasm32-unknown-unknown -- -D warnings` -- passed
- `cargo +nightly fmt --check` and web-storage format check -- passed
- `just web-build` -- passed; packaged WASM matches the production artifact
- Browser smoke test at `http://127.0.0.1:8000/` -- rendered the centered
  game, started gameplay, opened settings, and restored a changed language
  from browser storage in a fresh page with no runtime errors
2026-08-29 14:32:41 +02:00
ddidderr c2f1443436 fmt: just fmt (rust only)
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 09:57:39 +02:00
34 changed files with 3082 additions and 531 deletions
-27
View File
@@ -1,27 +0,0 @@
name: Build TDK Pinball
on:
push:
pull_request:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: tdkpin-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux development libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libgl1-mesa-dev libxi-dev
- run: cargo check --all-targets
- run: cargo test --all-targets
- run: cargo clippy --all-targets -- -D warnings
+2
View File
@@ -1,2 +1,4 @@
target/
__pycache__/
/tdkpin-rs/highscores.sqlite3
/tdkpin-rs/highscores.sqlite3-*
+21
View File
@@ -0,0 +1,21 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-undefined",
"-C",
"link-arg=--export=tdkpin_storage_crate_version",
"-C",
"link-arg=--export=tdkpin_browser_storage_clear",
"-C",
"link-arg=--export=tdkpin_browser_storage_push",
"-C",
"link-arg=--export=tdkpin_browser_storage_finish",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_revision",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_length",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_byte",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_ack",
]
+4
View File
@@ -5,6 +5,10 @@
Automatically commit changes once a full feature, bugfix, refactor, or other
coherent unit of work is finished. Do not wait for the user to ask for a commit.
Use `divergence` as the Conventional Commit scope for every change that
deliberately differs from original-game behavior to fix an original bug, for
example `fix(divergence): reject occupied wheel slots`.
## Versioning Policy
Only update the version, when the user explicitly asks for it.
+119 -58
View File
@@ -8,6 +8,66 @@ and this project adheres to
## [Unreleased]
## [1.2.0] - 2026-08-31
### Added
- Seed new SQLite high-score databases that did not exist before opening with
the ten distributed demo scores.
### Fixed
- Deliberately diverge from the original game's multiball contact sentinel so a
reserve ball that fills a wheel hole leaves it permanently occupied; the
surviving ball can no longer enter and score that visibly filled slot again.
- Match capture-driven multiball collapse timing so the surviving slot keeps
double scoring through the current callback, then returns to normal scoring on
the next callback and the returning claw remains paused until that collapse;
completing the fifth wheel lock still ends multiball scoring immediately.
- Derive wheel-lock awards from the original live contact words so two balls
settling into different slots at once receive the same accumulated award as
the original game.
- Stage an effect-seven reserve-ball request until ball 1's full substep batch
has returned, preventing the new slot from participating in collision and
capture rules before the original creates it.
- Bound high-score server resource usage with 1 KiB route body limits,
single-operation database concurrency gating, 503 shedding, and offloading
SQLite operations to blocking worker threads.
- Use a relative path for the web build's high-score API endpoint so deployments
under nested URL subpaths route requests correctly.
- Implement bounded exponential backoff with jitter, initial delay and maximum
delay bounds, and `Retry-After` header support for failed web high-score
submissions.
## [1.1.0] - 2026-08-29
### Added
- Add a static WebAssembly (WASM) browser build and web shell for TDK Pinball,
persisting settings and scores in browser localStorage.
- Add a standalone Axum and SQLite high-score service (`highscore-server`) for
an optional shared top-ten leaderboard, including an nginx reverse-proxy
configuration and API endpoints.
- Route the browser high-score table through the shared service when available,
retaining the local-storage fallback when offline or unconfigured.
### Fixed
- Allow launcher input while tilted so a waiting ball in the shooter lane is not
stranded after a tilt, while retaining launch sound suppression.
- Bundle the official Macroquad browser loader and WASM bootstrap locally so a
CSP does not require inline JavaScript or code from `not-fl3.github.io`.
- Keep the web canvas centered at the original fixed 640x460 presentation size
instead of scaling with the browser viewport.
- Seed the initial gameplay random stream from the startup clock so the first
maximum-power launcher shot is not identical on every run.
- Require an unconsumed record-148 contact before the effect-seven special
respawn, preventing a released reserve ball from being duplicated after both
multiball balls drain.
- Apply type-4 entry latches only inside each target's registered broadphase,
preventing an unrelated multiball slot from clearing an upper-left target's
latch and causing repeated scoring while the ball remains there.
## [1.0.0] - 2026-08-29
### Fixed
@@ -23,13 +83,13 @@ and this project adheres to
a held ball remains velocity-stationary and the moving response retains its
full normal velocity instead of always subtracting 1,000.
- Restore `1000:ae6e`'s required-ball special respawn: armed or completed
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming
a marker, clears required state including record 148's contact word, and
suppresses record 148 until the next broadphase-active type-4 record
reenables it.
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming a
marker, clears required state including record 148's contact word, and
suppresses record 148 until the next broadphase-active type-4 record reenables
it.
- Replace the invented post-move `y>470` fallback with the original pre-scan
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including
one-slot removal during multiball and next-substep handling after a response.
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including one-slot
removal during multiball and next-substep handling after a response.
- Remove the invented 180 ms whole-frame nudge shake; original nudges affect
ball-slot velocities, sound, and Tilt state without moving the viewport.
- Match `SndPlaySound(SND_ASYNC|SND_NODEFAULT)` playback: stop the previous WAV
@@ -40,8 +100,8 @@ and this project adheres to
collect-bonus, and release-ball states are now visible.
- Restore display blits from `1008:0996`/`1008:0b9e`: 130x40 score fields at
X=478, eight places ending at X=606, exact 139x31 configured-player rows,
11x11 active/status markers, 5x5 remaining-ball markers for every player,
and strict signed score-status thresholds independent of media awards.
11x11 active/status markers, 5x5 remaining-ball markers for every player, and
strict signed score-status thresholds independent of media awards.
- Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values
from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh
installs now inherit the distributed German help setting; F12 is again a
@@ -54,17 +114,17 @@ and this project adheres to
DAT995 at `(202,328)`, height 13 and clipped width `(stage-1)*7`; correct the
previously impossible Pascal-order C interpretation of a 328x202 blit.
- Restore the original idle/attract timer animation: target and word-quad
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809
item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500
marquee. Each nested phase uses the binary's remainder first: `%32/2`,
`%32/4`, `%40/4`, `%16/4`, and `%144/8`. Move gameplay item frames to their
exact `(123,300)` destination.
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 item
progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 marquee. Each
nested phase uses the binary's remainder first: `%32/2`, `%32/4`, `%40/4`,
`%16/4`, and `%144/8`. Move gameplay item frames to their exact `(123,300)`
destination.
- Match central score/event side effects: every stored collision candidate
clears its ball slot's capture age, Tilt suppresses all score and rule-record
mutations, and active multiball doubles points before permanent Double can
double them again.
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact
ID order with a separately tracked prediction. Preserve the raw scanner's
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact ID
order with a separately tracked prediction. Preserve the raw scanner's
first-detected-candidate quirk even when a later record is geometrically
nearer, plus type-3 and dynamic-record broadphase behavior.
- Run type-3/type-4 processing before response and position publication:
@@ -72,16 +132,16 @@ and this project adheres to
candidates, triggers mutate the live motion first, and sensor IDs execute in
binary order without Hold/Complete prematurely ending the record scan.
- Apply the collision table's predicted-position broadphase bounds before
type-1/type-2 detection, including the registered five-pixel ball margin,
and retain unresolved candidates until the selected response phase.
- Correct the two relative slingshot initializer chains after records 55 and
70: circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each
preceding path endpoint exactly as the 175-record ledger specifies. The
flagless TDK companion lines also retain their zero contact/link field rather
than carrying unused synthetic head IDs. Record 57 additionally matches the
live original's `(75530,354765)/(3000,15)` to
`(77369,356597)/(1839,1832)` transition; symmetric record 72 matches
`(235348,356825)/(-3000,15)` to `(233253,358494)/(-2095,1669)`.
type-1/type-2 detection, including the registered five-pixel ball margin, and
retain unresolved candidates until the selected response phase.
- Correct the two relative slingshot initializer chains after records 55 and 70:
circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each preceding
path endpoint exactly as the 175-record ledger specifies. The flagless TDK
companion lines also retain their zero contact/link field rather than carrying
unused synthetic head IDs. Record 57 additionally matches the live original's
`(75530,354765)/(3000,15)` to `(77369,356597)/(1839,1832)` transition;
symmetric record 72 matches `(235348,356825)/(-3000,15)` to
`(233253,358494)/(-2095,1669)`.
- Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when
the previous position is inside, then deactivate when the newly predicted
position exits any edge, replacing a swept-intersection approximation.
@@ -90,11 +150,11 @@ and this project adheres to
- Match the multiball nudge path's saved-slot behavior: consume the active
formula's random draws, then add one shared fixed/random impulse to both slot
velocity records instead of applying different formulas to each ball.
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line
records 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on
kicked unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer
1 or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line records
55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on kicked
unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer 1
or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Match the original detail callback's slot-major integration order, stop a
slot's remaining 10 ms substeps after its first collision/action, delay a
newly requested second ball until the next callback, and retain ball-two
@@ -102,9 +162,9 @@ and this project adheres to
- Run type-3 captures and type-4 triggers for both live ball slots, including
per-slot capture age/contact ownership, exact captured-slot removal, survivor
promotion, and the effect-seven ball-number guard against a third ball.
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact
`(0,3040)` millipixel velocity, instead of launching a guessed second ball
upward from the shooter lane.
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact `(0,3040)`
millipixel velocity, instead of launching a guessed second ball upward from
the shooter lane.
- Treat record 148 as the special/multiball hole represented by player item 20;
capturing it preserves all five lock-hole items and contacts instead of
inventing an immediate wheel reset. Its exact 17x17 overlay-A Word-Quad at
@@ -115,12 +175,12 @@ and this project adheres to
- Render the five cumulative bumper-value lamps from original player items 1-5
at their recovered Word-Quad bounds as the value rises from 1,000 to 6,000.
- Render the nine in-bank hit states for record heads 90/93/96/99/102 and
109/112/115/118 from their exact overlay-A bounds until bank completion
rearms each three-line group.
- Replace the free-running wheel animation with the original six-callback
91x90 target rotation, WAVE 2011 start, exact DAT600 frames/target positions,
and state-6 rotation of the five per-player contact/item values. It advances
only for an active ball, pauses in the launcher, and survives a normal drain.
109/112/115/118 from their exact overlay-A bounds until bank completion rearms
each three-line group.
- Replace the free-running wheel animation with the original six-callback 91x90
target rotation, WAVE 2011 start, exact DAT600 frames/target positions, and
state-6 rotation of the five per-player contact/item values. It advances only
for an active ball, pauses in the launcher, and survives a normal drain.
- Restore the complete 281-callback DAT600 panel sequence after the fifth lock
hole, including physics suspension, contact/item cleanup, all nine image
phases, and the original WAVE 2013/2012/stop boundaries. A fifth lock captured
@@ -131,9 +191,9 @@ and this project adheres to
- Remove the invented attract-screen instruction panel and stop assigning the
original F2/F3 keys to modern overlays. The idle view is the original table;
optional settings/high-score viewers move to F10/F9.
- Batch physics and flipper publication on the original 50/40/30/20/10-ms
detail callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of
exposing every substep on host render frames.
- Batch physics and flipper publication on the original 50/40/30/20/10-ms detail
callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of exposing
every substep on host render frames.
- Separate flipper key flags from rendered/collision position so press/release
edges and WAVE 2021 occur only when the detail timer processes them.
- Add a bit-exact Borland Real48 core and route speed clamps, type-1 circles,
@@ -141,8 +201,8 @@ and this project adheres to
magnetic impulses through its original rounding behavior.
- Preserve per-ball Real48 spin and use it in collision tangent response, so
zero-spin C fixtures and retained-spin Wine traces are both represented.
- Implement dynamic records 174/175 with bidirectional ball impulse transfer
and the original post-collision speed clamp instead of a one-sided secondary
- Implement dynamic records 174/175 with bidirectional ball impulse transfer and
the original post-collision speed clamp instead of a one-sided secondary
bounce.
- Split collision contact state into per-player 16-bit type-3 contact words and
transient type-4 entry flags. Type-3 records now use the original deep-inside
@@ -182,11 +242,12 @@ and this project adheres to
- Match the original low-byte keyboard scans: both Ctrl keys operate the left
flipper, both Enter keys operate the right, and the physical `0x1b`
main-keyboard plus/right-bracket position starts or adds players.
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release through
the original `1000:638e` timing rather than on their initial key press.
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release
through the original `1000:638e` timing rather than on their initial key
press.
- Enforce the original auxiliary-window input gate: F1 and F12 are ignored while
Help, HighScore, or name-entry UI is open instead of controlling the main
game through a child window.
Help, HighScore, or name-entry UI is open instead of controlling the main game
through a child window.
- Route a single-ball type-3 completion during Tilt through `1000:ae6e` instead
of granting a free launcher reset: lock holes consume the ball, while record
148 can still take its required-state special-respawn branch.
@@ -202,18 +263,18 @@ and this project adheres to
playback and restores bank, trigger, capture, nudge, tilt, and drain cues.
Type-4 WAVE 2004 and bumper WAVE 2006 now precede their score mutations so a
simultaneously crossed media marker leaves WAVE 2007 audible, as in the
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while
a following flipper-release edge may still play WAVE 2021 after reset.
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while a
following flipper-release edge may still play WAVE 2021 after reset.
Bumper/TDK bank completion WAVE 2017 likewise precedes its 50,000 or
10,000..24,464 award, preserving the marker cue when that award crosses one.
- Advance media/extra-ball thresholds only from score additions, preserve
32-bit score wrapping, and restore the ninth-diamond ordering: its 24,464
award remains single, then ball-scoped double scoring and all three magnetic
fields activate before the collision's static score. Further completed banks
during that ball add 100,000 to the secondary score; a normal ball end clears
the multiplier, while effect seven's special respawn preserves it. Marker
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering
after score wrap.
- Advance media/extra-ball thresholds only from score additions, preserve 32-bit
score wrapping, and restore the ninth-diamond ordering: its 24,464 award
remains single, then ball-scoped double scoring and all three magnetic fields
activate before the collision's static score. Further completed banks during
that ball add 100,000 to the secondary score; a normal ball end clears the
multiplier, while effect seven's special respawn preserves it. Marker
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering after
score wrap.
- Match the original 640x460 window, native 16x28 score glyphs, and
`TDK Pinball Machine 1.00` title so the recovered artwork is presented
one-for-one instead of enlarged or resampled.
+55 -1
View File
@@ -164,6 +164,42 @@ dependencies = [
"ttf-parser",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -362,6 +398,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.17.16"
@@ -481,6 +523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "0.6.14"
@@ -503,14 +551,20 @@ dependencies = [
[[package]]
name = "tdkpin-rs"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"directories",
"futures-util",
"macroquad",
"serde",
"serde_json",
"tdkpin-web-storage",
]
[[package]]
name = "tdkpin-web-storage"
version = "1.0.0"
[[package]]
name = "thiserror"
version = "2.0.20"
+8 -2
View File
@@ -1,14 +1,20 @@
[package]
name = "tdkpin-rs"
version = "1.0.0"
version = "1.2.0"
edition = "2024"
[dependencies]
directories = "6"
macroquad = { version = "0.4", features = ["audio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
directories = "6"
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = "0.3"
tdkpin-web-storage = { path = "web_storage" }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
+21 -4
View File
@@ -31,6 +31,20 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs
no extra runtime installation; macOS builds with the normal Apple developer
command-line tools.
## Browser version
Build and serve the WASM website locally with:
```sh
just web-serve
```
Then open <http://127.0.0.1:8000/>. The browser build keeps the original
640x460 presentation centered on a black page and stores settings and a local
fallback table in browser storage unless the optional same-origin
`/api/highscores` service is deployed. See [web/README.md](web/README.md) and
[highscore-server/README.md](highscore-server/README.md) for deployment details.
## Deterministic mechanics validation
Named scenarios can be advanced without waiting in real time. The simulator
@@ -75,10 +89,13 @@ box closes the program.
## Saved data
Settings and the ten-entry high-score table are stored as `save.json` in the
platform's normal per-user application-data directory. On first run, settings
are imported from an adjacent original INI (or the embedded distributed
TDKPIN.INI), and the table is imported from the original `HISCORES.DAT`.
Native settings and the ten-entry high-score table are stored as `save.json` in
the platform's normal per-user application-data directory. Browser settings
and the fallback table use `localStorage`; a deployed browser build uses the
same-origin high-score service as its shared table when available. On first
run, native settings are imported from an adjacent original INI (or the
embedded distributed TDKPIN.INI), and the table is imported from the original
`HISCORES.DAT`.
## Reconstruction status
+805
View File
@@ -0,0 +1,805 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tdkpin-highscore-server"
version = "0.1.0"
dependencies = [
"axum",
"http-body-util",
"rusqlite",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "tdkpin-highscore-server"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
[dev-dependencies]
http-body-util = "0.1"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tower = { version = "0.5", features = ["util"] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[profile.release]
debug = true
strip = false
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.production]
inherits = "release"
debug = false
strip = true
debug-assertions = false
overflow-checks = false
lto = true
incremental = false
codegen-units = 1
+37
View File
@@ -0,0 +1,37 @@
# TDK high-score service
This small Axum service stores the shared top-ten table in SQLite. It exposes:
```text
GET /healthz
GET /api/highscores
POST /api/highscores
```
The POST body is JSON with a 21-character maximum name and a `u32` score. The
response is the canonical top-ten JSON array.
A newly created database starts with the original distributed demo table. An
existing database, including an existing empty database, is left unchanged.
Submissions are anonymous and intentionally trust the browser's score. Add
rate limiting, moderation, or server-side run verification if the table needs
to resist forged scores.
Run it from `tdkpin-rs` with:
```sh
TDKPIN_HIGHSCORE_BIND=127.0.0.1:3000 \
TDKPIN_HIGHSCORE_DB=/var/lib/tdkpin/highscores.sqlite3 \
cargo run --manifest-path highscore-server/Cargo.toml
```
Copy the rate and connection zone declarations from
[nginx.conf.example](nginx.conf.example) into the existing `http` block, then
place its two `location` blocks inside the public site's `server` block. The
example bounds per-client and aggregate API traffic, request bodies, and proxy
waits. The browser client expects the API at `/api/highscores` on the same
origin as the game.
The crate inherits the parent [`rustfmt.toml`](../rustfmt.toml); run
`just fmt-highscore-server` when formatting it directly.
@@ -0,0 +1,36 @@
# Add these directives inside the existing nginx http block. The server-wide
# zones bound aggregate traffic, while the address-keyed zones prevent one
# client from consuming the whole allowance.
limit_req_zone $binary_remote_addr zone=tdkpin_highscore_client_rate:10m rate=5r/s;
limit_req_zone $server_name zone=tdkpin_highscore_global_rate:1m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=tdkpin_highscore_client_connections:10m;
limit_conn_zone $server_name zone=tdkpin_highscore_global_connections:1m;
# Add these blocks inside the server block that serves the game.
location /api/highscores {
limit_req zone=tdkpin_highscore_client_rate burst=10 nodelay;
limit_req zone=tdkpin_highscore_global_rate burst=25 nodelay;
limit_req_status 429;
limit_conn tdkpin_highscore_client_connections 10;
limit_conn tdkpin_highscore_global_connections 100;
limit_conn_status 429;
client_max_body_size 1k;
client_body_timeout 5s;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
proxy_next_upstream off;
}
# Optional health check for local monitoring.
location = /healthz {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+590
View File
@@ -0,0 +1,590 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use axum::{
Json,
Router,
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use rusqlite::{Connection, params, types::Type};
use serde::{Deserialize, Serialize};
use tokio::{sync::Semaphore, task};
const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21;
const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1;
const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
("Paul", 6_537_392),
("Paul Schulze", 2_979_000),
("Kalle", 2_393_464),
("Paul Schulze", 2_328_000),
("Martina Sommer", 2_326_000),
("Martina Sommer", 1_093_000),
("Sommer Martina", 1_027_000),
("No Name", 1_000_000),
("TDK Pinball Player", 923_000),
("Martina Sommer", 905_000),
];
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore {
pub name: String,
pub score: u32,
}
#[derive(Debug)]
pub enum StoreError {
Database(rusqlite::Error),
Poisoned,
}
impl From<rusqlite::Error> for StoreError {
fn from(error: rusqlite::Error) -> Self {
Self::Database(error)
}
}
#[derive(Clone)]
pub struct HighScoreStore(Arc<Mutex<Connection>>);
impl HighScoreStore {
/// Open or create a SQLite-backed high-score store.
///
/// # Errors
///
/// Returns the SQLite error raised while opening or initializing the
/// database.
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
let path = path.as_ref();
let seed_defaults = !path.exists();
Self::from_connection(Connection::open(path)?, seed_defaults)
}
/// Create an in-memory high-score store for tests or short-lived runs.
///
/// # Errors
///
/// Returns the SQLite error raised while initializing the database.
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open_in_memory()?, false)
}
fn from_connection(
mut connection: Connection,
seed_defaults: bool,
) -> Result<Self, rusqlite::Error> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295)
);
CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);",
)?;
if seed_defaults {
let transaction = connection.transaction()?;
for &(name, score) in DEFAULT_HIGH_SCORES {
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![name, i64::from(score)],
)?;
}
transaction.commit()?;
}
Ok(Self(Arc::new(Mutex::new(connection))))
}
/// Return the current table in descending score order.
///
/// # Errors
///
/// Returns an error if the database lock or query fails.
pub fn list(&self) -> Result<Vec<HighScore>, StoreError> {
let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let mut statement = connection.prepare(
"SELECT name, score
FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1",
)?;
let scores = statement
.query_map([i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)], |row| {
let score: i64 = row.get(1)?;
let score = u32::try_from(score).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(1, Type::Integer, Box::new(error))
})?;
Ok(HighScore {
name: row.get(0)?,
score,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(scores)
}
/// Add one score and return the resulting canonical top ten.
///
/// # Errors
///
/// Returns an error if the database lock or transaction fails.
pub fn submit(&self, entry: &HighScore) -> Result<Vec<HighScore>, StoreError> {
{
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?;
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![entry.name, i64::from(entry.score)],
)?;
transaction.execute(
"DELETE FROM high_scores
WHERE id NOT IN (
SELECT id FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1
)",
[i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)],
)?;
transaction.commit()?;
}
self.list()
}
}
#[derive(Clone)]
struct AppState {
store: HighScoreStore,
database_slots: Arc<Semaphore>,
}
impl AppState {
fn new(store: HighScoreStore) -> Self {
Self {
store,
database_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_DATABASE_OPERATIONS)),
}
}
}
#[derive(Debug)]
enum DatabaseRequestError {
Busy,
Failed,
}
async fn run_database_operation<T, F>(
state: &AppState,
operation: F,
) -> Result<T, DatabaseRequestError>
where
T: Send + 'static,
F: FnOnce(&HighScoreStore) -> Result<T, StoreError> + Send + 'static,
{
let permit = state
.database_slots
.clone()
.try_acquire_owned()
.map_err(|_| DatabaseRequestError::Busy)?;
let store = state.store.clone();
task::spawn_blocking(move || {
let _permit = permit;
operation(&store)
})
.await
.map_err(|_| DatabaseRequestError::Failed)?
.map_err(|_| DatabaseRequestError::Failed)
}
#[derive(Debug, Deserialize)]
struct SubmitRequest {
name: String,
score: u32,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: &'static str,
}
fn invalid_request(message: &'static str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: message }),
)
.into_response()
}
fn database_error_response(error: &DatabaseRequestError) -> Response {
match error {
DatabaseRequestError::Busy => (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "service is busy",
}),
)
.into_response(),
DatabaseRequestError::Failed => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str> {
let name = request.name.trim();
if name.is_empty() {
return Err("name must not be empty");
}
if name.chars().count() > MAX_NAME_CHARS {
return Err("name is too long");
}
if name.chars().any(char::is_control) {
return Err("name contains a control character");
}
Ok(HighScore {
name: name.to_owned(),
score: request.score,
})
}
async fn health() -> &'static str {
"ok"
}
async fn list_high_scores(State(state): State<AppState>) -> Response {
match run_database_operation(&state, HighScoreStore::list).await {
Ok(scores) => Json(scores).into_response(),
Err(error) => database_error_response(&error),
}
}
async fn submit_high_score(
State(state): State<AppState>,
Json(request): Json<SubmitRequest>,
) -> Response {
let entry = match validate_request(&request) {
Ok(entry) => entry,
Err(message) => return invalid_request(message),
};
match run_database_operation(&state, move |store| store.submit(&entry)).await {
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
Err(error) => database_error_response(&error),
}
}
pub fn router(store: HighScoreStore) -> Router {
router_with_state(AppState::new(store))
}
fn router_with_state(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health))
.route(
"/api/highscores",
get(list_high_scores)
.post(submit_high_score)
.layer(DefaultBodyLimit::max(MAX_SUBMISSION_BODY_BYTES)),
)
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::{sync::mpsc, time::Duration};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tempfile::tempdir;
use tower::ServiceExt;
use super::*;
async fn submit(app: &Router, name: &str, score: u32) -> StatusCode {
app.clone()
.oneshot(
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"name": name,
"score": score,
}))
.expect("request JSON should encode"),
))
.expect("request should build"),
)
.await
.expect("router should respond")
.status()
}
async fn request(app: &Router, request: Request<Body>) -> StatusCode {
app.clone()
.oneshot(request)
.await
.expect("router should respond")
.status()
}
fn hold_database_lock(
store: HighScoreStore,
) -> (mpsc::SyncSender<()>, std::thread::JoinHandle<()>) {
let (locked_sender, locked_receiver) = mpsc::sync_channel(0);
let (release_sender, release_receiver) = mpsc::sync_channel(0);
let lock_thread = std::thread::spawn(move || {
let _connection = store.0.lock().expect("database lock should succeed");
locked_sender
.send(())
.expect("test should observe the held database lock");
release_receiver
.recv()
.expect("test should release the database lock");
});
locked_receiver
.recv()
.expect("database lock thread should start");
(release_sender, lock_thread)
}
async fn wait_until_database_is_busy(database_slots: &Semaphore) {
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("first database request should acquire admission");
}
async fn list(app: &Router) -> Vec<HighScore> {
let response = app
.clone()
.oneshot(
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should read")
.to_bytes();
serde_json::from_slice(&body).expect("response should contain scores")
}
#[tokio::test]
async fn api_persists_and_keeps_the_top_ten() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open");
let app = router(store);
for score in 0..12 {
assert_eq!(
submit(&app, &format!("Player {score}"), score).await,
StatusCode::CREATED
);
}
let scores = list(&app).await;
assert_eq!(scores.len(), MAX_HIGH_SCORES);
assert_eq!(scores[0].score, 11);
assert_eq!(scores[9].score, 2);
drop(app);
let reopened = router(HighScoreStore::open(&database).expect("database should reopen"));
assert_eq!(list(&reopened).await, scores);
}
#[test]
fn new_database_starts_with_the_original_demo_scores() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
let store = HighScoreStore::open(&database).expect("database should open");
let scores = store.list().expect("scores should list");
let expected = DEFAULT_HIGH_SCORES
.iter()
.map(|&(name, score)| HighScore {
name: name.to_owned(),
score,
})
.collect::<Vec<_>>();
assert_eq!(scores, expected);
}
#[test]
fn existing_database_is_not_seeded() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open");
assert!(store.list().expect("scores should list").is_empty());
}
#[tokio::test]
async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
assert_eq!(submit(&app, "", 10).await, StatusCode::BAD_REQUEST);
assert_eq!(
submit(&app, "abcdefghijklmnopqrstuv", 10).await,
StatusCode::BAD_REQUEST
);
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_rejects_oversized_submission_bodies() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let body = serde_json::to_vec(&serde_json::json!({
"name": "Player",
"score": 10,
"padding": "x".repeat(MAX_SUBMISSION_BODY_BYTES),
}))
.expect("request JSON should encode");
let status = request(
&app,
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(body))
.expect("request should build"),
)
.await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn busy_database_load_sheds_api_without_blocking_health() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let accepted_app = app.clone();
let accepted = tokio::spawn(async move { submit(&accepted_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
assert_eq!(
tokio::time::timeout(
Duration::from_millis(250),
request(
&app,
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
),
)
.await
.expect("health request should not wait for the database"),
StatusCode::OK
);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::builder()
.method("HEAD")
.uri("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
assert_eq!(
accepted.await.expect("accepted request should complete"),
StatusCode::CREATED
);
lock_thread
.join()
.expect("database lock thread should stop");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn canceled_request_holds_admission_until_blocking_work_stops() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let canceled_app = app.clone();
let canceled = tokio::spawn(async move { submit(&canceled_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
canceled.abort();
assert!(
canceled
.await
.expect_err("request should be canceled")
.is_cancelled()
);
assert_eq!(database_slots.available_permits(), 0);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
lock_thread
.join()
.expect("database lock thread should stop");
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("completed blocking work should release admission");
assert_eq!(submit(&app, "Other Player", 20).await, StatusCode::CREATED);
}
#[tokio::test]
async fn health_endpoint_is_available_for_nginx() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let response = app
.oneshot(
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
}
}
+17
View File
@@ -0,0 +1,17 @@
use std::{env, error::Error, path::PathBuf};
use axum::Router;
use tdkpin_highscore_server::{HighScoreStore, router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let bind = env::var("TDKPIN_HIGHSCORE_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_owned());
let database = env::var_os("TDKPIN_HIGHSCORE_DB")
.map_or_else(|| PathBuf::from("highscores.sqlite3"), PathBuf::from);
let app: Router = router(HighScoreStore::open(database)?);
let listener = TcpListener::bind(&bind).await?;
println!("TDK high-score service listening on http://{bind}");
axum::serve(listener, app).await?;
Ok(())
}
+26 -4
View File
@@ -9,10 +9,32 @@ build:
build-release:
cargo build --release
build-production:
build-production-highscore-server:
cargo build --manifest-path highscore-server/Cargo.toml --profile production
build-production: build-production-highscore-server
cargo build --profile production
fmt:
web-build:
cargo build --profile production --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/production/tdkpin-rs.wasm web/tdkpin-rs.wasm
web-serve: web-build
python3 -m http.server 8000 --directory web
highscore-server:
cargo run --manifest-path highscore-server/Cargo.toml
test-highscore-server:
cargo test --manifest-path highscore-server/Cargo.toml
clippy-highscore-server:
cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings
fmt-highscore-server:
cargo +nightly fmt --manifest-path highscore-server/Cargo.toml
fmt: fmt-highscore-server
cargo +nightly fmt
tombi format
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
@@ -25,10 +47,10 @@ _fix:
fix: _fix fmt
clippy:
clippy: clippy-highscore-server
cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
test: test-highscore-server
cargo test --workspace --all-targets --all-features
clean:
+114 -61
View File
@@ -1,24 +1,68 @@
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use macroquad::prelude::*;
use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{
HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score,
HighScore,
Language,
Persistence,
SavedData,
insert_high_score,
qualifies_high_score,
},
};
use macroquad::prelude::*;
use std::path::Path;
const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.0;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal];
const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
[(9.0, 41.0), (23.0, 11.0), (56.0, 14.0), (63.0, 47.0), (35.0, 63.0)],
[(11.0, 45.0), (18.0, 13.0), (52.0, 11.0), (63.0, 42.0), (39.0, 64.0)],
[(13.0, 50.0), (15.0, 17.0), (46.0, 9.0), (64.0, 37.0), (44.0, 62.0)],
[(17.0, 55.0), (11.0, 23.0), (39.0, 7.0), (64.0, 30.0), (50.0, 59.0)],
[(21.0, 60.0), (9.0, 28.0), (35.0, 7.0), (62.0, 25.0), (54.0, 56.0)],
[(8.0, 35.0), (26.0, 8.0), (59.0, 18.0), (59.0, 50.0), (27.0, 61.0)],
[
(9.0, 41.0),
(23.0, 11.0),
(56.0, 14.0),
(63.0, 47.0),
(35.0, 63.0),
],
[
(11.0, 45.0),
(18.0, 13.0),
(52.0, 11.0),
(63.0, 42.0),
(39.0, 64.0),
],
[
(13.0, 50.0),
(15.0, 17.0),
(46.0, 9.0),
(64.0, 37.0),
(44.0, 62.0),
],
[
(17.0, 55.0),
(11.0, 23.0),
(39.0, 7.0),
(64.0, 30.0),
(50.0, 59.0),
],
[
(21.0, 60.0),
(9.0, 28.0),
(35.0, 7.0),
(62.0, 25.0),
(54.0, 56.0),
],
[
(8.0, 35.0),
(26.0, 8.0),
(59.0, 18.0),
(59.0, 50.0),
(27.0, 61.0),
],
];
const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [
(103, 304, 121, 323),
@@ -50,6 +94,13 @@ fn add_player_released() -> bool {
ADD_PLAYER_KEYS.into_iter().any(is_key_released)
}
fn initial_game_random_seed() -> u32 {
// The original seeds RandSeed from the startup clock. Macroquad's random
// generator starts from a fixed zero state unless the application seeds it.
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
macroquad::rand::rand()
}
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
let mut digits = Vec::with_capacity(8);
for position in 0..8 {
@@ -116,11 +167,7 @@ impl AttractAnimation {
fn target_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
32
} else {
index * 2
};
let first_activation = if index == 0 { 32 } else { index * 2 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
}
@@ -137,11 +184,7 @@ impl AttractAnimation {
fn record_strip_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 {
40
} else {
index * 4
};
let first_activation = if index == 0 { 40 } else { index * 4 };
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
}
@@ -151,11 +194,7 @@ impl AttractAnimation {
fn item_index(self) -> Option<usize> {
let phase = (self.tick % 144) / 8;
let item = if phase < 10 {
phase
} else {
18 - phase
};
let item = if phase < 10 { phase } else { 18 - phase };
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
}
@@ -204,7 +243,7 @@ impl App {
screen: Screen::Loading,
return_screen: Screen::Attract,
game: None,
game_random_seed: macroquad::rand::rand(),
game_random_seed: initial_game_random_seed(),
setting_row: 0,
loading_started,
loading_until: loading_started + 1.1,
@@ -216,6 +255,10 @@ impl App {
}
pub fn frame(&mut self) {
#[cfg(target_arch = "wasm32")]
self.assets.update();
#[cfg(target_arch = "wasm32")]
self.update_remote_high_scores();
self.handle_global_input();
match self.screen {
Screen::Loading => {
@@ -235,11 +278,13 @@ impl App {
self.present();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game);
self.screen = Screen::Playing;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_attract(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Attract;
@@ -249,6 +294,7 @@ impl App {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_loading(&mut self, steps: u64) {
self.game = None;
self.screen = Screen::Loading;
@@ -257,12 +303,14 @@ impl App {
self.loading_until = get_time() + 60.0;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_highscores(&mut self) {
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_name_entry(&mut self) {
self.game = Some(Game::new(1));
self.return_screen = Screen::Playing;
@@ -271,10 +319,12 @@ impl App {
self.screen = Screen::NameEntry;
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_simulation(&self) {
self.draw_logical();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path
.to_str()
@@ -420,6 +470,7 @@ impl App {
let now = get_time();
if now - self.last_help_click <= 0.40 {
self.save();
#[cfg(not(target_arch = "wasm32"))]
macroquad::miniquad::window::request_quit();
}
self.last_help_click = now;
@@ -495,7 +546,8 @@ impl App {
self.name.pop();
}
let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse);
let clicked_ok = click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_ok =
click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_cancel =
click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point));
if is_key_pressed(KeyCode::Escape) || clicked_cancel {
@@ -507,13 +559,13 @@ impl App {
}
fn accept_high_score_name(&mut self) {
insert_high_score(
&mut self.saved.high_scores,
HighScore {
name: self.name.clone(),
score: self.pending_score,
},
);
let entry = HighScore {
name: self.name.clone(),
score: self.pending_score,
};
insert_high_score(&mut self.saved.high_scores, entry.clone());
#[cfg(target_arch = "wasm32")]
self.persistence.submit_high_score(&entry);
self.save();
if self.game.as_ref().is_some_and(|game| game.finished) {
self.take_game_and_preserve_random_seed();
@@ -574,11 +626,8 @@ impl App {
}
if self.attract.magnetic_records_active() {
for (x, y, width, height) in [
(151, 389, 13, 49),
(11, 344, 13, 49),
(291, 346, 13, 49),
] {
for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)]
{
self.draw_active_table_region(x, y, width, height);
}
}
@@ -605,12 +654,7 @@ impl App {
}
if let Some(item) = self.attract.item_index() {
draw_texture(
&self.assets.diamond[item],
123.0,
300.0,
WHITE,
);
draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE);
}
self.draw_intro_marquee();
}
@@ -929,7 +973,15 @@ impl App {
} else if (66..=128).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
if frame < 83 {
draw_texture_region(wide_a, 79.0, ((82 - frame) * 2) as f32, 91.0, 90.0, 0.0, 0.0);
draw_texture_region(
wide_a,
79.0,
((82 - frame) * 2) as f32,
91.0,
90.0,
0.0,
0.0,
);
} else {
let height = (128 - frame) * 2;
draw_texture_region(
@@ -1122,14 +1174,7 @@ impl App {
draw_centered("SETTINGS", 122.0, 30, BLACK);
let rows = [
format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed),
format!(
"SOUND: {}",
if self.sounds_enabled {
"ON"
} else {
"OFF"
}
),
format!("SOUND: {}", if self.sounds_enabled { "ON" } else { "OFF" }),
format!("LANGUAGE: {}", self.saved.settings.language.label()),
];
for (index, row) in rows.iter().enumerate() {
@@ -1181,7 +1226,13 @@ impl App {
draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255));
draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE);
draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY);
draw_rectangle(x + 4.0, y + 4.0, 262.0, 20.0, Color::from_rgba(0, 0, 128, 255));
draw_rectangle(
x + 4.0,
y + 4.0,
262.0,
20.0,
Color::from_rgba(0, 0, 128, 255),
);
draw_text(
"Congratulations! This is a Top Ten Score!",
x + 9.0,
@@ -1265,6 +1316,14 @@ impl App {
eprintln!("could not save settings and highscores: {error}");
}
}
#[cfg(target_arch = "wasm32")]
fn update_remote_high_scores(&mut self) {
if let Some(scores) = self.persistence.take_high_scores() {
self.saved.high_scores = scores;
self.save();
}
}
}
fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
@@ -1319,13 +1378,7 @@ mod tests {
#[test]
fn attract_timer_uses_the_selected_detail_callback() {
for (detail, interval) in [
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut animation = AttractAnimation::default();
animation.update(interval - 0.001, detail);
assert_eq!(animation.tick, 0);
+130 -73
View File
@@ -1,3 +1,7 @@
#[cfg(target_arch = "wasm32")]
use futures_util::future::join_all;
#[cfg(target_arch = "wasm32")]
use macroquad::experimental::coroutines::{Coroutine, start_coroutine};
use macroquad::{
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
prelude::{FilterMode, Image, Texture2D},
@@ -24,6 +28,8 @@ pub struct Assets {
pub ball: Texture2D,
pub digits: Texture2D,
sounds: Vec<(u16, Sound)>,
#[cfg(target_arch = "wasm32")]
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
}
impl Assets {
@@ -33,7 +39,8 @@ impl Assets {
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.png"));
let highscore_background = texture(include_bytes!("../assets/original/images/dat_00993.png"));
let highscore_background =
texture(include_bytes!("../assets/original/images/dat_00993.png"));
let help = [
texture(include_bytes!("../assets/original/images/dat_01001.png")),
texture(include_bytes!("../assets/original/images/dat_01002.png")),
@@ -84,78 +91,18 @@ impl Assets {
let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
let sound_bytes: [(u16, &[u8]); 16] = [
(
2001,
include_bytes!("../assets/original/audio/wav_02001.wav"),
),
(
2002,
include_bytes!("../assets/original/audio/wav_02002.wav"),
),
(
2004,
include_bytes!("../assets/original/audio/wav_02004.wav"),
),
(
2006,
include_bytes!("../assets/original/audio/wav_02006.wav"),
),
(
2007,
include_bytes!("../assets/original/audio/wav_02007.wav"),
),
(
2008,
include_bytes!("../assets/original/audio/wav_02008.wav"),
),
(
2011,
include_bytes!("../assets/original/audio/wav_02011.wav"),
),
(
2012,
include_bytes!("../assets/original/audio/wav_02012.wav"),
),
(
2013,
include_bytes!("../assets/original/audio/wav_02013.wav"),
),
(
2015,
include_bytes!("../assets/original/audio/wav_02015.wav"),
),
(
2016,
include_bytes!("../assets/original/audio/wav_02016.wav"),
),
(
2017,
include_bytes!("../assets/original/audio/wav_02017.wav"),
),
(
2019,
include_bytes!("../assets/original/audio/wav_02019.wav"),
),
(
2020,
include_bytes!("../assets/original/audio/wav_02020.wav"),
),
(
2021,
include_bytes!("../assets/original/audio/wav_02021.wav"),
),
(
2022,
include_bytes!("../assets/original/audio/wav_02022.wav"),
),
];
let mut sounds = Vec::with_capacity(sound_bytes.len());
for (id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound));
}
}
let sound_bytes = sound_bytes();
#[cfg(target_arch = "wasm32")]
let (sounds, sound_loader) = (
Vec::new(),
Some(start_coroutine(
async move { load_sounds(&sound_bytes).await },
)),
);
#[cfg(not(target_arch = "wasm32"))]
let sounds = load_sounds(&sound_bytes).await;
#[cfg(target_arch = "wasm32")]
macroquad::window::next_frame().await;
Self {
active_table,
@@ -178,9 +125,23 @@ impl Assets {
ball,
digits,
sounds,
#[cfg(target_arch = "wasm32")]
sound_loader,
}
}
#[cfg(target_arch = "wasm32")]
pub fn update(&mut self) {
let Some(loader) = self.sound_loader.as_ref() else {
return;
};
if !loader.is_done() {
return;
}
let loader = self.sound_loader.take().expect("sound loader must exist");
self.sounds = loader.retrieve().unwrap_or_default();
}
pub fn play(&self, id: u16, enabled: bool) {
if !enabled {
return;
@@ -204,6 +165,102 @@ impl Assets {
}
}
fn sound_bytes() -> [(u16, &'static [u8]); 16] {
[
(
2001,
include_bytes!("../assets/original/audio/wav_02001.wav"),
),
(
2002,
include_bytes!("../assets/original/audio/wav_02002.wav"),
),
(
2004,
include_bytes!("../assets/original/audio/wav_02004.wav"),
),
(
2006,
include_bytes!("../assets/original/audio/wav_02006.wav"),
),
(
2007,
include_bytes!("../assets/original/audio/wav_02007.wav"),
),
(
2008,
include_bytes!("../assets/original/audio/wav_02008.wav"),
),
(
2011,
include_bytes!("../assets/original/audio/wav_02011.wav"),
),
(
2012,
include_bytes!("../assets/original/audio/wav_02012.wav"),
),
(
2013,
include_bytes!("../assets/original/audio/wav_02013.wav"),
),
(
2015,
include_bytes!("../assets/original/audio/wav_02015.wav"),
),
(
2016,
include_bytes!("../assets/original/audio/wav_02016.wav"),
),
(
2017,
include_bytes!("../assets/original/audio/wav_02017.wav"),
),
(
2019,
include_bytes!("../assets/original/audio/wav_02019.wav"),
),
(
2020,
include_bytes!("../assets/original/audio/wav_02020.wav"),
),
(
2021,
include_bytes!("../assets/original/audio/wav_02021.wav"),
),
(
2022,
include_bytes!("../assets/original/audio/wav_02022.wav"),
),
]
}
async fn load_sounds(sound_bytes: &[(u16, &[u8])]) -> Vec<(u16, Sound)> {
#[cfg(target_arch = "wasm32")]
{
join_all(sound_bytes.iter().map(|(id, bytes)| async move {
load_sound_from_bytes(bytes)
.await
.ok()
.map(|sound| (*id, sound))
}))
.await
.into_iter()
.flatten()
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut sounds = Vec::with_capacity(sound_bytes.len());
for &(id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound));
}
}
sounds
}
}
fn texture(bytes: &[u8]) -> Texture2D {
let texture = Texture2D::from_file_with_format(bytes, None);
texture.set_filter(FilterMode::Nearest);
+5 -4
View File
@@ -77,15 +77,16 @@ mod tests {
fn zero_bounds_still_advance_the_seed() {
let mut random = BorlandRandom::new(7);
assert_eq!(random.below(0), 0);
assert_eq!(random.seed(), 7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1));
assert_eq!(
random.seed(),
7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1)
);
}
#[test]
fn unit_interval_is_the_exact_unsigned_seed_fraction() {
let mut random = BorlandRandom::new(0xfedc_ba98);
let expected_seed = 0xfedc_ba98_u32
.wrapping_mul(MULTIPLIER)
.wrapping_add(1);
let expected_seed = 0xfedc_ba98_u32.wrapping_mul(MULTIPLIER).wrapping_add(1);
assert_eq!(
random.unit_interval().to_bits(),
(f64::from(expected_seed) / TWO_TO_32).to_bits()
+70 -29
View File
@@ -5,8 +5,7 @@ use crate::{original_physics::MilliVec, real48::Real48};
const SEARCH_RADIUS: i32 = 54_000;
const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]);
const TWO: Real48 = Real48::from_bytes([0x82, 0, 0, 0, 0, 0]);
const TWO_FIFTHS: Real48 =
Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
const TWO_FIFTHS: Real48 = Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -171,14 +170,11 @@ fn penetration(geometry: Geometry, delta: i32, ball: MilliVec) -> i32 {
.wrapping_add(43_000)
.wrapping_sub(geometry.pivot.y),
);
geometry
.pivot
.y
.wrapping_sub(
Real48::from_i32(numerator)
.divide(Real48::from_i32(edge_dx))
.round_i32(),
)
geometry.pivot.y.wrapping_sub(
Real48::from_i32(numerator)
.divide(Real48::from_i32(edge_dx))
.round_i32(),
)
} else {
geometry.positive_edge.y.wrapping_add(43_000)
}
@@ -299,8 +295,14 @@ mod tests {
let velocities = [
MilliVec { x: 0, y: 2_000 },
MilliVec { x: 1_000, y: 2_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec { x: 2_700, y: -2_700 },
MilliVec {
x: -1_000,
y: 2_000,
},
MilliVec {
x: 2_700,
y: -2_700,
},
];
let mut hash = 14_695_981_039_346_656_037_u64;
let mut hits = 0_u32;
@@ -315,10 +317,10 @@ mod tests {
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
3_800,
);
let (hit, velocity, movement) = response.map_or(
(0_u32, input_velocity, MilliVec::default()),
|response| (1, response.velocity, response.movement),
);
let (hit, velocity, movement) = response
.map_or((0_u32, input_velocity, MilliVec::default()), |response| {
(1, response.velocity, response.movement)
});
hits += hit;
for value in [
i32_bits(x),
@@ -342,40 +344,73 @@ mod tests {
fn four_direction_vectors_match_the_reconstructed_c_harness() {
let cases = [
(
MilliVec { x: 104_000, y: 384_000 },
MilliVec {
x: 104_000,
y: 384_000,
},
MilliVec { x: 1_000, y: 2_000 },
-1,
FlipperSide::Left,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MilliVec { x: 1_266, y: 1_511 },
MilliVec { x: -6_703, y: -8_000 },
MilliVec {
x: -6_703,
y: -8_000,
},
),
(
MilliVec { x: 209_000, y: 419_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec {
x: 209_000,
y: 419_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
1,
FlipperSide::Right,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]),
MilliVec { x: -737, y: 2_263 },
MilliVec { x: -5_578, y: 17_127 },
MilliVec {
x: -5_578,
y: 17_127,
},
),
(
MilliVec { x: 104_000, y: 421_000 },
MilliVec {
x: 104_000,
y: 421_000,
},
MilliVec { x: 1_000, y: 2_000 },
1,
FlipperSide::Left,
Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]),
MilliVec { x: 622, y: 2_320 },
MilliVec { x: 5_414, y: 20_193 },
MilliVec {
x: 5_414,
y: 20_193,
},
),
(
MilliVec { x: 209_000, y: 385_000 },
MilliVec { x: -1_000, y: 2_000 },
MilliVec {
x: 209_000,
y: 385_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
-1,
FlipperSide::Right,
Real48::ZERO,
MilliVec { x: -1_280, y: 1_560 },
MilliVec { x: 7_385, y: -9_000 },
MilliVec {
x: -1_280,
y: 1_560,
},
MilliVec {
x: 7_385,
y: -9_000,
},
),
];
for (ball, velocity, delta, side, response, expected_velocity, expected_movement) in cases {
@@ -398,7 +433,10 @@ mod tests {
fn boundary_vectors_match_the_live_original_binary() {
for (ball, velocity, delta, side, expected_velocity, expected_movement) in [
(
MilliVec { x: 100_000, y: 370_000 },
MilliVec {
x: 100_000,
y: 370_000,
},
MilliVec { x: 1_000, y: 2_000 },
-1,
FlipperSide::Left,
@@ -406,7 +444,10 @@ mod tests {
MilliVec::default(),
),
(
MilliVec { x: 209_000, y: 419_000 },
MilliVec {
x: 209_000,
y: 419_000,
},
MilliVec { x: 1_000, y: 2_000 },
1,
FlipperSide::Right,
+401 -184
View File
@@ -1,20 +1,34 @@
use macroquad::prelude::{Rect, Vec2, vec2};
use crate::{
borland_random::BorlandRandom,
flipper_physics::{FlipperSide, moving_flipper_response},
geometry::Segment,
original_physics::{
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, StaticCollisionCandidate,
ball_collision_response, capture_collision_candidate, circle_collision_candidate_at,
line_collision_candidate_at, milli_distance, path_intersects_circle,
CollisionMaterial,
CollisionResponse,
GRAVITY_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP,
MilliVec,
StaticCollisionCandidate,
ball_collision_response,
capture_collision_candidate,
circle_collision_candidate_at,
line_collision_candidate_at,
milli_distance,
path_intersects_circle,
},
real48::Real48,
table::{
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS,
BUMPERS,
EFFECT_SENSOR,
LOCK_HOLES,
PASSIVE_CIRCLES,
SPECIAL_HOLE_SENSOR,
TARGET_SENSORS,
WALLS,
},
};
use macroquad::prelude::{Rect, Vec2, vec2};
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
@@ -368,6 +382,7 @@ pub struct Game {
}
impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand())
}
@@ -446,6 +461,7 @@ impl Game {
true
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER;
@@ -455,6 +471,7 @@ impl Game {
events
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
self.target_effect = effect.min(7);
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
@@ -464,6 +481,7 @@ impl Game {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false;
self.ball.position = vec2(170.0, 230.0);
@@ -483,7 +501,8 @@ impl Game {
let variation = self.random.below(3_800) / 40;
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
} else if launch_velocity > -2_280 {
launch_velocity = launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
launch_velocity =
launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
}
self.ball.in_launcher = false;
self.ball.velocity = MilliVec {
@@ -516,14 +535,15 @@ impl Game {
} else if self.tilted {
self.flipper_release_latch[1] = true;
}
self.flipper_inputs.left_raised = controls.left_flipper
&& !self.tilted
&& !self.flipper_release_latch[0];
self.flipper_inputs.right_raised = controls.right_flipper
&& !self.tilted
&& !self.flipper_release_latch[1];
self.flipper_inputs.left_raised =
controls.left_flipper && !self.tilted && !self.flipper_release_latch[0];
self.flipper_inputs.right_raised =
controls.right_flipper && !self.tilted && !self.flipper_release_latch[1];
if self.ball.in_launcher && !self.tilted {
// The original key handlers keep accepting the launcher key while
// tilted; Tilt disables flippers and scoring, but must not strand a
// ball that is still waiting in the shooter lane.
if self.ball.in_launcher {
if controls.launch_down {
if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05);
@@ -546,7 +566,9 @@ impl Game {
} else if self.launcher_was_down {
self.fire_launcher();
events.push(Event::Launch);
events.push(Event::Sound(2002));
if !self.tilted {
events.push(Event::Sound(2002));
}
}
}
if !self.tilted && controls.nudge != Nudge::None {
@@ -570,22 +592,31 @@ impl Game {
self.update_target_rotation(events);
}
let panel_active = self.update_panel_completion(events);
// The original collapses an inactive two-ball slot immediately before
// the next physics pass. Capture removal does not clear multiball
// scoring until that collapse, so the surviving slot retains double
// scoring for the remainder of the callback in which it was captured.
if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball {
self.score_mode = ScoreMode::Normal;
}
if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
let mut spawned_secondary = None;
if had_secondary_ball {
self.score_mode = ScoreMode::Multiball;
}
for _ in 0..substeps {
if self.fixed_update(events)
|| self.finished
|| self.claw.ball_suspended
{
let stop = self.fixed_update(events);
// Effect seven publishes its spawn request during record 149,
// but the original timer does not create slot two until the
// complete slot-one simulation pass has returned.
if !had_secondary_ball && spawned_secondary.is_none() {
spawned_secondary = self.secondary_ball.take();
}
if stop || self.finished || self.claw.ball_suspended {
break;
}
}
if had_secondary_ball && self.secondary_ball.is_none() {
self.score_mode = ScoreMode::Normal;
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
@@ -607,24 +638,24 @@ impl Game {
}
}
}
if let Some(spawned) = spawned_secondary {
debug_assert!(self.secondary_ball.is_none());
self.secondary_ball = Some(spawned);
}
}
self.pending_flipper_edges[0] = match (
self.flipper_inputs.left_raised,
self.flippers.left_raised,
) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[1] = match (
self.flipper_inputs.right_raised,
self.flippers.right_raised,
) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[0] =
match (self.flipper_inputs.left_raised, self.flippers.left_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.pending_flipper_edges[1] =
match (self.flipper_inputs.right_raised, self.flippers.right_raised) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.flippers = self.flipper_inputs;
for edge in self.pending_flipper_edges {
if edge != 0 {
@@ -706,7 +737,11 @@ impl Game {
match nudge {
Nudge::Left | Nudge::Right => {
let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
let signed = if nudge == Nudge::Left { -amount } else { amount };
let signed = if nudge == Nudge::Left {
-amount
} else {
amount
};
velocity.x = velocity.x.wrapping_add(signed);
}
Nudge::Center => {
@@ -726,12 +761,10 @@ impl Game {
}
if let Some(secondary) = &mut self.secondary_ball {
let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => {
MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
y: 0,
}
}
Nudge::Left | Nudge::Right => MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
y: 0,
},
Nudge::Center => {
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
@@ -1070,13 +1103,7 @@ impl Game {
best = Some((id, false, candidate));
}
ball.velocity = velocity.to_velocity_per_second();
(
best,
SensorScanResult {
action,
},
predicted,
)
(best, SensorScanResult { action }, predicted)
}
fn find_static_collision_candidate_in_range(
@@ -1317,12 +1344,7 @@ impl Game {
collided || scan.action == BallAction::Suspend
}
fn apply_bumper_rule(
&mut self,
object_id: u8,
auxiliary_fired: bool,
events: &mut Vec<Event>,
) {
fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec<Event>) {
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
return;
}
@@ -1504,11 +1526,7 @@ impl Game {
if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains(
predicted_position,
sensor.center,
sensor.radius,
)
&& trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains(
predicted_position,
@@ -1549,56 +1567,32 @@ impl Game {
}
}
if let Some(completed_action) =
self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
{
action = completed_action;
}
self.check_target_sensors(
ball,
old_position,
movement_velocity,
140..=147,
events,
);
if let Some(completed_action) =
self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
{
action = completed_action;
}
self.check_effect_sensor(
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
old_position,
movement_velocity,
predicted_position,
&mut capture_candidate,
events,
);
self.check_target_sensors(
ball,
old_position,
movement_velocity,
150..=152,
events,
);
SensorScanResult {
action,
) {
action = completed_action;
}
self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events);
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
) {
action = completed_action;
}
self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events);
self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
SensorScanResult { action }
}
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
@@ -1753,7 +1747,17 @@ impl Game {
events: &mut Vec<Event>,
) -> BallAction {
self.wheel_holes[index] = true;
let filled = self.wheel_holes.iter().filter(|filled| **filled).count();
// Deliberate original-game divergence: a multiball capture normally
// leaves owner 2 here, which lets the surviving single ball capture
// the visibly occupied hole once more. Occupied wheel holes remain
// permanent in the clone regardless of which ball completed them.
self.record_contacts[129 + index] = 99;
// 1000:967a derives the award from all live type-three contact words,
// including another ball that is still settling into a lock hole.
let filled = self.record_contacts[129..=133]
.iter()
.filter(|contact| **contact != 0)
.count();
let shift = u32::try_from(filled.min(5)).unwrap_or(5);
let award = 5_000_u32 << shift;
let player = &mut self.players[self.current_player];
@@ -1768,6 +1772,7 @@ impl Game {
if ball_count == 1 {
self.panel_frame = Some(0);
}
self.score_mode = ScoreMode::Normal;
}
events.push(Event::Lock);
if ball_count == 1 {
@@ -1832,7 +1837,14 @@ impl Game {
return false;
}
let effect_index = usize::from(EFFECT_SENSOR.id);
if self.object_active[effect_index] {
let predicted_position = old_position.add(movement_velocity);
if self.object_active[effect_index]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
)
{
let touched = path_intersects_circle(
old_position,
movement_velocity,
@@ -1858,7 +1870,8 @@ impl Game {
self.players[self.current_player].secondary_score = 0;
}
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)]
!= ball_number
&& self.secondary_ball.is_none()
&& matches!(
self.multiball_state,
@@ -1898,17 +1911,27 @@ impl Game {
return false;
}
let mut triggered = false;
let predicted_position = old_position.add(movement_velocity);
for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) {
continue;
}
let contact_index = usize::from(sensor.id);
// The original scanner does not call a type-4 handler until the
// predicted point is inside that record's registered bounds. In
// multiball this also prevents an unrelated ball outside the
// corridor from clearing the ball that is currently inside it.
if !self.object_active[contact_index]
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
{
continue;
}
let touched = path_intersects_circle(
old_position,
movement_velocity,
sensor.center,
sensor.radius,
);
let contact_index = usize::from(sensor.id);
let entered = touched && !self.trigger_flags[contact_index];
self.trigger_flags[contact_index] = touched;
if !entered {
@@ -1999,10 +2022,7 @@ impl Game {
.round_i32()
.wrapping_neg();
let predicted = old_position.add(*velocity);
if predicted.x < min_x
|| predicted.x > max_x
|| predicted.y < min_y
|| predicted.y > max_y
if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y
{
self.object_active[object_id] = false;
}
@@ -2057,6 +2077,7 @@ impl Game {
}
}
#[cfg(not(target_arch = "wasm32"))]
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active {
@@ -2094,7 +2115,7 @@ impl Game {
}
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
if !self.claw.active {
if !self.claw.active || self.score_mode == ScoreMode::Multiball {
return;
}
self.claw.frame_accumulator += dt;
@@ -2179,6 +2200,7 @@ impl Game {
return;
}
self.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled;
let player = &mut self.players[self.current_player];
if player.extra_balls > 0 {
@@ -2225,7 +2247,11 @@ impl Game {
fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active)
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
)
}
fn save_current_rule_state(&mut self) {
@@ -2336,16 +2362,14 @@ fn claw_release(frame: u8) -> (Vec2, Vec2) {
fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if let Some(response) =
moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
)
{
if let Some(response) = moving_flipper_response(
position,
velocity,
delta,
side,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MAXIMUM_SPEED_MILLI_PER_STEP,
) {
ball.position = position.add(response.movement).to_position();
ball.velocity = response.velocity.to_velocity_per_second();
}
@@ -2379,11 +2403,7 @@ mod tests {
game.ball.in_launcher = false;
game.ball.position = center;
game.ball.velocity = Vec2::ZERO;
game.check_sensor_objects(
MilliVec::from_position(center),
MilliVec::default(),
events,
);
game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
}
}
@@ -2397,7 +2417,8 @@ mod tests {
) -> CaptureStep {
let mut ball = game.ball;
let mut capture_candidate = None;
let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let predicted_position =
previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step(
&mut ball,
1,
@@ -2456,8 +2477,8 @@ mod tests {
game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false;
game.ball.position = midpoint - normal;
game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0)
.to_velocity_per_second();
game.ball.velocity =
MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) {
missed_walls.push(wall.id);
@@ -2478,7 +2499,10 @@ mod tests {
}
}
assert!(missed_walls.is_empty(), "missed production wall records: {missed_walls:?}");
assert!(
missed_walls.is_empty(),
"missed production wall records: {missed_walls:?}"
);
assert!(
missed_circles.is_empty(),
"missed production circle records: {missed_circles:?}"
@@ -2656,6 +2680,17 @@ mod tests {
assert_eq!(game.launcher_frame(), 10);
}
#[test]
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
let mut first = Game::new_with_seed(1, 1);
let mut second = Game::new_with_seed(1, 2);
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.ball.velocity, second.ball.velocity);
}
#[test]
fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 {
@@ -2802,6 +2837,7 @@ mod tests {
let mut special = Game::new(1);
special.ball_double = BallDoubleState::Active;
special.multiball_state = MultiballState::Ready;
special.record_contacts[148] = 2;
special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
@@ -2869,9 +2905,11 @@ mod tests {
assert_eq!(special.multiball_state, MultiballState::Unavailable);
assert_eq!(special.record_contacts[148], 0);
assert!(!special_events.contains(&Event::Drain));
assert!(!special_events
.iter()
.any(|event| event.sound_resource().is_some()));
assert!(
!special_events
.iter()
.any(|event| event.sound_resource().is_some())
);
}
#[test]
@@ -3179,10 +3217,7 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: 0,
y: 3_055,
}
MilliVec { x: 0, y: 3_055 }
);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3219,6 +3254,10 @@ mod tests {
assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
game.drain(&mut events);
assert!(game.ball.in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
events.clear();
game.apply_wall_rule(84, &mut events);
@@ -3240,6 +3279,7 @@ mod tests {
fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Active;
game.record_contacts[148] = 2;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
@@ -3255,10 +3295,7 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec {
x: -302,
y: -1_809,
}
MilliVec { x: -302, y: -1_809 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3297,9 +3334,7 @@ mod tests {
spin: Real48::ZERO,
capture_age: 0,
};
let old_position = MilliVec::from_position(
sensor.center - vec2(sensor.radius + 1.0, 0.0),
);
let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0));
let current_position = MilliVec::from_position(sensor.center);
let movement = MilliVec {
x: current_position.x.wrapping_sub(old_position.x),
@@ -3391,7 +3426,10 @@ mod tests {
BallAction::Keep
);
assert_eq!(game.secondary_ball.map(|ball| ball.position), Some(secondary.position));
assert_eq!(
game.secondary_ball.map(|ball| ball.position),
Some(secondary.position)
);
assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(game.target_effect, 0);
}
@@ -3409,15 +3447,71 @@ mod tests {
spin: Real48::ZERO,
capture_age: 300,
});
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.score_mode, ScoreMode::Multiball);
assert_eq!(game.ball.capture_age, 17);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
assert!(events.contains(&Event::Lock));
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.target_effect = 1;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.timer_tick(0.01, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().score, 1_000);
assert_eq!(game.player().secondary_score, 20_000);
assert_eq!(game.score_mode, ScoreMode::Multiball);
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn active_multiball_pauses_the_returning_claw_until_slot_collapse() {
let mut game = Game::new(1);
game.claw.active = true;
game.claw.frame = 1;
game.claw.target_frame = 10;
game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 1);
game.score_mode = ScoreMode::Normal;
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
}
#[test]
@@ -3449,10 +3543,35 @@ mod tests {
assert_eq!(game.ball.position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity);
assert_eq!(game.ball.capture_age, survivor.capture_age);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
}
#[test]
fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 99;
game.wheel_holes[0] = true;
let mut events = Vec::new();
let action = game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert_eq!(action, BallAction::Keep);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.player().secondary_score, 0);
assert!(!events.contains(&Event::Lock));
assert!(!game.ball.in_launcher);
}
#[test]
fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1);
@@ -3511,7 +3630,6 @@ mod tests {
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
@@ -3605,10 +3723,7 @@ mod tests {
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(57));
assert_eq!(
game.ball.spin.bytes(),
[0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]
);
assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
@@ -3645,10 +3760,7 @@ mod tests {
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(72));
assert_eq!(
game.ball.spin.bytes(),
[0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]
);
assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec {
@@ -3658,7 +3770,10 @@ mod tests {
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec { x: -2_095, y: 1_669 }
MilliVec {
x: -2_095,
y: 1_669
}
);
}
@@ -3928,8 +4043,7 @@ mod tests {
#[test]
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))]
{
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(object_id)] = true;
@@ -4012,7 +4126,13 @@ mod tests {
let velocity_after_edge = game.ball.velocity;
assert_eq!(game.ball.position, vec2(138.124, 355.0));
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
assert_eq!(raw_velocity, MilliVec { x: 2_209, y: -5_891 });
assert_eq!(
raw_velocity,
MilliVec {
x: 2_209,
y: -5_891
}
);
assert_eq!(game.pending_flipper_edges, [0, 0]);
game.apply_flipper_kicks();
@@ -4102,20 +4222,20 @@ mod tests {
#[test]
fn tilt_counter_decays_once_per_selected_detail_callback() {
for (detail, interval) in [
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
let mut game = Game::new(1);
game.tilt_counter = 2;
game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.tilt_counter, 2, "detail {detail} decayed before its callback");
assert_eq!(
game.tilt_counter, 2,
"detail {detail} decayed before its callback"
);
game.update(0.001_1, detail, Controls::default());
assert_eq!(game.tilt_counter, 1, "detail {detail} did not decay at its callback");
assert_eq!(
game.tilt_counter, 1,
"detail {detail} did not decay at its callback"
);
}
}
@@ -4340,7 +4460,7 @@ mod tests {
);
assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 70.0);
game.ball.position = vec2(205.0, 62.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
@@ -4355,6 +4475,47 @@ mod tests {
assert_eq!(game.player().score, 1_000);
}
#[test]
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.ball;
primary.position = sensor.center;
let mut events = Vec::new();
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
let mut unrelated = primary;
unrelated.position = vec2(200.0, 200.0);
let unrelated_position = MilliVec::from_position(unrelated.position);
game.check_target_sensors(
&mut unrelated,
unrelated_position,
MilliVec::default(),
140..=147,
&mut events,
);
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
}
#[test]
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
let mut game = Game::new_with_seed(1, 7);
@@ -4462,11 +4623,7 @@ mod tests {
5
);
complete_stationary_type_three_capture(
&mut game,
SPECIAL_HOLE_SENSOR.center,
&mut events,
);
complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events);
assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.record_contacts[148], 2);
@@ -4480,6 +4637,23 @@ mod tests {
);
}
#[test]
fn wheel_award_counts_another_ball_settling_in_a_different_hole() {
let mut game = Game::new(1);
game.record_contacts[129] = 1;
game.record_contacts[130] = 2;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(1, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.player().secondary_score, 20_000);
assert!(!game.wheel_holes[0]);
assert!(game.wheel_holes[1]);
}
#[test]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1);
@@ -4497,6 +4671,7 @@ mod tests {
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None);
assert_eq!(game.score_mode, ScoreMode::Normal);
let mut reset_by_special_hole = game.clone();
reset_by_special_hole.reset_ball_to_launcher();
@@ -4696,7 +4871,11 @@ mod tests {
assert_eq!(game.panel_frame, None);
assert_eq!(game.wheel_holes, [false; 5]);
assert!(game.record_contacts[129..=133].iter().all(|contact| *contact == 0));
assert!(
game.record_contacts[129..=133]
.iter()
.all(|contact| *contact == 0)
);
assert_eq!(
events,
[
@@ -4758,7 +4937,11 @@ mod tests {
game.record_countdowns[51] = 5;
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), 5, "launcher idle pauses countdowns");
assert_eq!(
game.record_countdown(51),
5,
"launcher idle pauses countdowns"
);
game.ball.in_launcher = false;
for expected in (0..5).rev() {
@@ -4876,4 +5059,38 @@ mod tests {
assert!(game.tilted);
assert!(game.ball.in_launcher);
}
#[test]
fn tilted_waiting_ball_can_still_be_launched() {
let mut game = Game::new_with_seed(1, 7);
for _ in 0..2 {
game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
game.update(
0.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
assert!(game.launcher_frame() > 0);
let events = game.update(0.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!events.contains(&Event::Sound(2002)));
assert!(!game.ball.in_launcher);
assert!(game.tilted);
}
}
+16 -10
View File
@@ -7,11 +7,13 @@ mod geometry;
mod original_physics;
mod persistence;
mod real48;
#[cfg(not(target_arch = "wasm32"))]
mod simulation;
mod table;
use app::App;
use macroquad::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use simulation::{Request, Scenario, Simulation, usage};
const WINDOW_WIDTH: i32 = 640;
@@ -30,18 +32,21 @@ fn window_conf() -> Conf {
#[macroquad::main(window_conf)]
async fn main() {
let request = match Request::parse(std::env::args()) {
Ok(request) => request,
Err(error) => {
eprintln!("{error}");
#[cfg(not(target_arch = "wasm32"))]
{
let request = match Request::parse(std::env::args()) {
Ok(request) => request,
Err(error) => {
eprintln!("{error}");
return;
}
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return;
}
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return;
}
let mut app = App::load().await;
@@ -51,6 +56,7 @@ async fn main() {
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn run_simulation(request: Request) -> Result<(), String> {
let mut simulation = Simulation::new(request.scenario, request.seed);
simulation.advance_to(request.target_step);
+31 -38
View File
@@ -73,11 +73,7 @@ impl CollisionMaterial {
}
}
pub const fn circle(
normal_rebound: f64,
tangent_coupling: f64,
normal_kick: f64,
) -> Self {
pub const fn circle(normal_rebound: f64, tangent_coupling: f64, normal_kick: f64) -> Self {
Self {
normal_rebound,
tangent_coupling,
@@ -173,8 +169,7 @@ impl MilliVec {
// `maximum / speed` is mathematically equivalent, but can differ by
// one millipixel because each Real48 operation is rounded
// independently.
let excess = Real48::from_i32(speed.wrapping_sub(maximum))
.divide(Real48::from_i32(speed));
let excess = Real48::from_i32(speed.wrapping_sub(maximum)).divide(Real48::from_i32(speed));
self.x = self
.x
.wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32());
@@ -262,11 +257,7 @@ fn tangent_velocity(velocity: MilliVec, normal_x: Real48, normal_y: Real48, leng
.round_i32()
}
fn cross_at_endpoint(
point: MilliVec,
current: MilliVec,
predicted: MilliVec,
) -> i32 {
fn cross_at_endpoint(point: MilliVec, current: MilliVec, predicted: MilliVec) -> i32 {
predicted
.x
.wrapping_sub(point.x)
@@ -383,9 +374,7 @@ pub fn line_collision_candidate_at(
let vertical_units = normal_y.divide(THOUSAND);
let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x))
.multiply(vertical_units)
.subtract(
Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units),
)
.subtract(Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units))
.divide(length_units);
let distance = if distance.compare(ZERO).is_lt() {
ZERO.subtract(distance)
@@ -525,10 +514,7 @@ pub fn capture_collision_candidate(
let radius_milli = (radius * 1_000.0).round() as i32;
let delta = MilliVec {
x: center.x.wrapping_sub(old_position.x),
y: center
.y
.wrapping_sub(old_position.y)
.wrapping_sub(2_000),
y: center.y.wrapping_sub(old_position.y).wrapping_sub(2_000),
};
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
@@ -541,9 +527,7 @@ pub fn capture_collision_candidate(
return None;
}
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
if normal_velocity >= 0
|| normal_velocity.wrapping_abs() < surface_distance.wrapping_abs()
{
if normal_velocity >= 0 || normal_velocity.wrapping_abs() < surface_distance.wrapping_abs() {
return None;
}
Some(StaticCollisionCandidate {
@@ -681,8 +665,14 @@ mod tests {
#[test]
fn float_views_roundtrip_every_gameplay_velocity_millipixel() {
for value in -10_000..=10_000 {
let milli = MilliVec { x: value, y: -value };
assert_eq!(MilliVec::from_velocity_per_second(milli.to_velocity_per_second()), milli);
let milli = MilliVec {
x: value,
y: -value,
};
assert_eq!(
MilliVec::from_velocity_per_second(milli.to_velocity_per_second()),
milli
);
}
}
@@ -697,8 +687,7 @@ mod tests {
if speed <= maximum {
continue;
}
let excess = Real48::from_i32(speed - maximum)
.divide(Real48::from_i32(speed));
let excess = Real48::from_i32(speed - maximum).divide(Real48::from_i32(speed));
let expected = MilliVec {
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
@@ -716,7 +705,10 @@ mod tests {
break;
}
}
assert!(mismatches.is_empty(), "speed-clamp mismatches: {mismatches:?}");
assert!(
mismatches.is_empty(),
"speed-clamp mismatches: {mismatches:?}"
);
}
#[test]
@@ -783,7 +775,9 @@ mod tests {
assert_eq!(candidate.surface_distance, -800);
assert_ne!(
candidate.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO).velocity,
candidate
.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO)
.velocity,
MilliVec { x: 1_000, y: 0 }
);
}
@@ -887,16 +881,15 @@ mod tests {
fn surface_distance_orders_candidate_contacts() {
let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 };
let near =
line_collision_response(
old,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
CollisionMaterial::line(0.6, 0.1),
Real48::ZERO,
)
.expect("near rail should be crossed");
let near = line_collision_response(
old,
velocity,
vec2(2.0, 1.0),
vec2(2.0, -1.0),
CollisionMaterial::line(0.6, 0.1),
Real48::ZERO,
)
.expect("near rail should be crossed");
let far = line_collision_response(
old,
velocity,
+64 -11
View File
@@ -1,6 +1,12 @@
use std::io;
#[cfg(not(target_arch = "wasm32"))]
use std::{fs, path::PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf};
#[cfg(target_arch = "wasm32")]
use tdkpin_web_storage::{queue_high_score, queue_save, take_high_scores, take_loaded};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI");
@@ -74,10 +80,15 @@ impl Default for SavedData {
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Persistence {
path: PathBuf,
}
#[cfg(target_arch = "wasm32")]
pub struct Persistence;
#[cfg(not(target_arch = "wasm32"))]
impl Persistence {
pub fn new() -> Self {
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
@@ -91,11 +102,7 @@ impl Persistence {
fs::read_to_string(&self.path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
})
.unwrap_or_else(native_default_saved_data)
}
pub fn save(&self, data: &SavedData) -> io::Result<()> {
@@ -117,6 +124,47 @@ impl Persistence {
}
}
#[cfg(target_arch = "wasm32")]
impl Persistence {
pub fn new() -> Self {
Self
}
#[allow(clippy::unused_self)]
pub fn load(&self) -> SavedData {
take_loaded()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
#[allow(clippy::unused_self)]
pub fn save(&self, data: &SavedData) -> io::Result<()> {
let encoded = serde_json::to_vec(data).map_err(io::Error::other)?;
queue_save(encoded);
Ok(())
}
pub fn take_high_scores(&self) -> Option<Vec<HighScore>> {
take_high_scores().and_then(|bytes| serde_json::from_slice(&bytes).ok())
}
pub fn submit_high_score(&self, entry: &HighScore) {
if let Ok(encoded) = serde_json::to_vec(entry) {
queue_high_score(encoded);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_default_saved_data() -> SavedData {
SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_adjacent_original_settings() -> Option<Settings> {
let executable = std::env::current_exe().ok()?;
let exact_name = executable.with_extension("INI");
@@ -154,7 +202,10 @@ pub fn parse_original_settings(text: &str) -> Settings {
_ => Language::English,
};
} else if key.trim().eq_ignore_ascii_case("Speed") {
settings.speed = u8::try_from(value).ok().filter(|speed| (1..=5).contains(speed)).unwrap_or(3);
settings.speed = u8::try_from(value)
.ok()
.filter(|speed| (1..=5).contains(speed))
.unwrap_or(3);
} else if key.trim().eq_ignore_ascii_case("Sound") {
settings.sounds = value != 0;
}
@@ -249,14 +300,16 @@ mod tests {
let settings = parse_original_settings(ORIGINAL_INI);
assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::German);
assert!(settings.sounds, "the original reads singular Sound, not Sounds");
assert!(
settings.sounds,
"the original reads singular Sound, not Sounds"
);
}
#[test]
fn original_ini_values_are_case_insensitive_and_clamped() {
let settings = parse_original_settings(
"[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n",
);
let settings =
parse_original_settings("[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n");
assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::Spanish);
assert!(!settings.sounds);
+7 -13
View File
@@ -167,8 +167,7 @@ impl Real48 {
}
let mut numerator = self.significand();
let denominator = right.significand();
let mut exponent =
i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
let mut exponent = i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
if numerator < denominator {
numerator <<= 1;
exponent -= 1;
@@ -254,16 +253,8 @@ impl Real48 {
assert!(highest_bit < 32, "Real48 integer overflow");
let significand = self.significand();
let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit;
let mut magnitude = if shift >= 64 {
0
} else {
significand >> shift
};
if round
&& shift > 0
&& shift <= 40
&& (significand >> (shift - 1)) & 1 != 0
{
let mut magnitude = if shift >= 64 { 0 } else { significand >> shift };
if round && shift > 0 && shift <= 40 && (significand >> (shift - 1)) & 1 != 0 {
magnitude += 1;
}
let limit = if self.negative() {
@@ -344,7 +335,10 @@ mod tests {
assert_eq!(HALF.round_i32(), 1);
assert_eq!(Real48::from_bytes([0x80, 0, 0, 0, 0, 0x80]).round_i32(), -1);
assert_eq!(ONE.compare(TWO), Ordering::Less);
assert_eq!(Real48::from_i32(-2).compare(Real48::from_i32(-1)), Ordering::Less);
assert_eq!(
Real48::from_i32(-2).compare(Real48::from_i32(-1)),
Ordering::Less
);
assert_eq!(TWO.sqrt().bytes(), [0x81, 0xfa, 0x33, 0xf3, 0x04, 0x35]);
assert_eq!(
ONE_AND_HALF.sqrt().bytes(),
+33 -10
View File
@@ -1,11 +1,13 @@
use crate::game::{ClawSpriteBank, Controls, Event, Game};
use serde::Serialize;
use std::{
fs,
path::{Path, PathBuf},
str::FromStr,
};
use serde::Serialize;
use crate::game::{ClawSpriteBank, Controls, Event, Game};
pub const SIMULATION_HZ: u32 = 120;
const SIMULATION_HZ_F64: f64 = 120.0;
const SIMULATION_DT: f32 = 1.0 / 120.0;
@@ -376,9 +378,7 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls {
| Scenario::Claw1
| Scenario::Claw6
| Scenario::Claw7
| Scenario::Claw18 => {
Controls::default()
}
| Scenario::Claw18 => Controls::default(),
}
}
@@ -507,9 +507,24 @@ mod tests {
.iter()
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
.collect::<Vec<_>>();
assert_eq!(events.iter().filter(|event| **event == "StopSound").count(), 3);
assert_eq!(events.iter().filter(|event| **event == "Sound(2013)").count(), 4);
assert_eq!(events.iter().filter(|event| **event == "Sound(2012)").count(), 2);
assert_eq!(
events.iter().filter(|event| **event == "StopSound").count(),
3
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2013)")
.count(),
4
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2012)")
.count(),
2
);
}
#[test]
@@ -521,7 +536,10 @@ mod tests {
simulation.advance_to(22);
assert_eq!(simulation.game.target_rotation_state, Some(6));
assert_eq!(simulation.game.wheel_holes, [false, true, false, false, true]);
assert_eq!(
simulation.game.wheel_holes,
[false, true, false, false, true]
);
simulation.advance_to(26);
assert_eq!(simulation.game.target_rotation_state, None);
}
@@ -561,7 +579,12 @@ mod tests {
.filter(|event| **event == "ClawRelease")
.count();
assert!(captures >= 1, "{} must enter the claw", scenario.name());
assert_eq!(releases, 1, "{} must release its seeded capture", scenario.name());
assert_eq!(
releases,
1,
"{} must release its seeded capture",
scenario.name()
);
assert!(captures >= releases);
assert!(simulation.game.ball.position.is_finite());
assert!(simulation.game.ball.velocity.is_finite());
+6 -2
View File
@@ -9,9 +9,10 @@
//! each object's bounds by `param_26`. They must not be enlarged again by the
//! radius of the rendered ball.
use crate::geometry::Segment;
use macroquad::prelude::Vec2;
use crate::geometry::Segment;
#[derive(Clone, Copy, Debug)]
pub struct TableSegment {
pub id: u8,
@@ -425,7 +426,10 @@ mod tests {
.iter()
.find(|wall| wall.id == id)
.expect("relative line record must be present");
assert_eq!((wall.segment.start, wall.segment.end), (expected_start, expected_end));
assert_eq!(
(wall.segment.start, wall.segment.end),
(expected_start, expected_end)
);
}
for (id, expected_center) in [
(54, Vec2::new(53.0, 292.0)),
+25
View File
@@ -0,0 +1,25 @@
# TDK Pinball Machine web build
Build and serve the browser version from this directory with:
```sh
just web-serve
```
The game is compiled for `wasm32-unknown-unknown` and loaded into a fixed
640x460 canvas centered on the black page by `index.html`. Browser settings and
a fallback copy of the high-score table are saved in `localStorage`; native
builds continue to use their normal per-user save file.
The page includes the official Macroquad browser loader locally. The WASM
bootstrap is local as well, so the page does not require inline or third-party
JavaScript. The web page must be served over HTTP rather than opened directly
from a `file:` URL.
For a strict Content Security Policy, allow `script-src 'self'
'wasm-unsafe-eval'` for the browser's WebAssembly compilation and
`connect-src 'self'` for the local WASM and optional high-score API requests.
When the same-origin `/api/highscores` endpoint is available, the browser loads
and submits the shared top-ten table there. The small Axum service and an nginx
proxy example are in [highscore-server](../highscore-server/).
+3
View File
@@ -0,0 +1,3 @@
"use strict";
load("tdkpin-rs.wasm");
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
name="description"
content="Play the reconstructed 1995 TDK Pinball Machine in your browser."
/>
<title>TDK Pinball Machine 1.00</title>
<style>
:root {
color-scheme: dark;
background: #000;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
min-width: 640px;
min-height: 460px;
overflow: auto;
background: #000;
}
body {
display: grid;
place-items: center;
}
#glcanvas {
display: block;
width: 640px;
height: 460px;
background: #000;
outline: none;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="glcanvas" tabindex="1" aria-label="TDK Pinball Machine"></canvas>
<noscript>This game needs JavaScript enabled.</noscript>
<script src="./mq_js_bundle.js"></script>
<script src="./storage.js"></script>
<script src="./bootstrap.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+193
View File
@@ -0,0 +1,193 @@
"use strict";
(function registerStoragePlugin() {
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const highScoreApi = "api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0;
let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false;
let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false;
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
let highScoreRetryAt = 0;
function browserStorage() {
try {
return window.localStorage;
} catch (_error) {
return null;
}
}
function sendSavedDataToRust() {
wasm_exports.tdkpin_browser_storage_clear();
const storage = browserStorage();
let saved = null;
try {
saved = storage?.getItem(storageKey);
} catch (_error) {
saved = null;
}
if (saved !== null && saved !== undefined) {
for (const byte of encoder.encode(saved)) {
wasm_exports.tdkpin_browser_storage_push(byte);
}
}
wasm_exports.tdkpin_browser_storage_finish();
}
function flushRustSave() {
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
if (revision === lastRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_storage_save_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
}
const storage = browserStorage();
try {
storage?.setItem(storageKey, decoder.decode(bytes));
} catch (_error) {
// Private browsing or a full quota should not stop the game loop.
}
wasm_exports.tdkpin_browser_storage_save_ack();
lastRevision = revision;
}
function deliverHighScores(json) {
wasm_exports.tdkpin_browser_high_scores_clear();
for (const byte of encoder.encode(json)) {
wasm_exports.tdkpin_browser_high_scores_push(byte);
}
wasm_exports.tdkpin_browser_high_scores_finish();
}
async function fetchHighScores() {
const generation = ++highScoreGeneration;
try {
const response = await fetch(highScoreApi, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`high-score request failed (${response.status})`);
}
const json = await response.text();
if (generation === highScoreGeneration) {
deliverHighScores(json);
}
} catch (error) {
console.warn("shared high scores unavailable; using local scores", error);
}
}
function retryAfterDelay(response) {
if (response.status !== 429 && response.status !== 503) {
return null;
}
const value = response.headers.get("Retry-After")?.trim();
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
const delay = seconds * 1000;
return Number.isFinite(delay) ? delay : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
}
function scheduleHighScoreRetry(retryAfterMs) {
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
const delay =
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
highScoreRetryAt = Date.now() + delay;
highScoreRetryDelayMs = Math.min(
highScoreRetryDelayMs * 2,
highScoreRetryMaxDelayMs,
);
}
function resetHighScoreRetry() {
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
highScoreRetryAt = 0;
}
async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) {
return;
}
if (Date.now() < highScoreRetryAt) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_high_score_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_high_score_byte(index);
}
highScoreRequestInFlight = true;
let retryAfterMs = null;
try {
const response = await fetch(highScoreApi, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: decoder.decode(bytes),
keepalive: true,
});
if (!response.ok) {
retryAfterMs = retryAfterDelay(response);
throw new Error(`high-score submission failed (${response.status})`);
}
const json = await response.text();
highScoreGeneration += 1;
deliverHighScores(json);
wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false;
resetHighScoreRetry();
} catch (error) {
scheduleHighScoreRetry(retryAfterMs);
if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true;
}
} finally {
highScoreRequestInFlight = false;
}
}
function onInit() {
sendSavedDataToRust();
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
void fetchHighScores();
window.setInterval(flushRustSave, 50);
window.setInterval(() => void flushHighScoreSubmission(), 50);
window.addEventListener("beforeunload", flushRustSave);
}
miniquad_add_plugin({
name: "tdkpin_storage",
on_init: onInit,
version: 1,
});
})();
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "tdkpin-web-storage"
version = "1.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
+134
View File
@@ -0,0 +1,134 @@
#![allow(unsafe_code)]
use std::{
cell::{Cell, RefCell},
mem,
};
thread_local! {
static LOADED_BYTES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static LOADED_HIGH_SCORES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static PENDING_SAVE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static PENDING_HIGH_SCORE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static SAVE_REVISION: Cell<u32> = const { Cell::new(0) };
static HIGH_SCORE_REVISION: Cell<u32> = const { Cell::new(0) };
}
pub fn take_loaded() -> Option<Vec<u8>> {
let bytes = LOADED_BYTES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_save(bytes: Vec<u8>) {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = Some(bytes));
SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
pub fn take_high_scores() -> Option<Vec<u8>> {
let bytes = LOADED_HIGH_SCORES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_high_score(bytes: Vec<u8>) {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = Some(bytes));
HIGH_SCORE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_storage_crate_version() -> u32 {
1
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_clear() {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_clear() {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 {
SAVE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_length() -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_ack() {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = None);
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_revision() -> u32 {
HIGH_SCORE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_length() -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_byte(index: u32) -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_ack(revision: u32) {
if HIGH_SCORE_REVISION.with(Cell::get) == revision {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = None);
}
}