diff --git a/tools/claude-skills/README.md b/tools/claude-skills/README.md new file mode 100644 index 00000000..86daa881 --- /dev/null +++ b/tools/claude-skills/README.md @@ -0,0 +1,98 @@ +# Claude Code skills for skate3rust + +Skills for [Claude Code](https://docs.claude.com/en/docs/claude-code) (an AI coding agent) that capture how this +engine is built, tested, researched and kept retail-accurate. Each skill is a folder with a `SKILL.md` (front matter +`name` + `description`, which the agent uses to decide when to load it) and, where needed, helper scripts in +`tools/`. They are plain Markdown, so they also work as human-readable checklists. + +## Install + +Skills are loaded from `.claude/skills//SKILL.md` of the project (the checkout you run Claude Code in) or from +`~/.claude/skills//SKILL.md` for all projects. The repo's `.gitignore` ignores `/.claude/`, so the shared copies +live here and you install them per checkout. + +Copy (Git Bash, from the repo root): +```bash +mkdir -p .claude/skills +cp -r tools/claude-skills/*/ .claude/skills/ +``` +Or link them so they follow `git pull` (PowerShell, from the repo root; directory junctions need no admin rights): +```powershell +New-Item -ItemType Directory -Force .claude\skills | Out-Null +Get-ChildItem tools\claude-skills -Directory | ForEach-Object { + New-Item -ItemType Junction -Path ".claude\skills\$($_.Name)" -Target $_.FullName | Out-Null +} +``` +Install into the project's `.claude/skills/` rather than `~/.claude/skills/` when you want to use the helper scripts: +several of them locate the repo root from their own path (`/.claude/skills//tools/...`). +Restart Claude Code (or start a new session) after installing; `/skills` lists what it found. Invoke a skill by +asking for the task it describes, or by name (for example `/regression-check`). + +## The skills + +| Skill | What it is for | +|---|---| +| `build-and-run` | Build (dev and release link configurations), stage into `bin\`, test, smoke-test and launch the game; screenshot mode; audio diagnostics switches. | +| `asset-setup` | Convert your own Skate 3 ISO / extracted disc into assets headlessly, refresh after converter changes, re-point the `assets` junction, diagnose spawns and invisible walls, repair the pro roster. | +| `regression-check` | What to verify after converter, game code, input, water, audio, native dependency or performance changes: every-map spawn and collision baselines, test targets, smoke runs, fingerprint impact, "is this failure mine or upstream's". | +| `vendor-patch` | Vendor and patch a crates.io dependency (Bevy, sdl3-sys) and handle native C/C++ dependencies in both CRT configurations. | +| `make-mod` | Write and validate Lua API 2 mods, including the audio and world-audio mod APIs on the audio branches; moddability rules for engine features. | +| `prior-work-check` | Search upstream PRs/issues, forks and sibling projects before starting work. | +| `conflict-resolution` | Merge branches without losing lines: reuse existing resolutions, hunk-level reuse, a lost-line checker, semantic-conflict detection, stacking conflicting PRs. | +| `optimisation` | Performance work with proof of identical behaviour: measure first, byte-identical e2e hashes, an adversarial review checklist, worked examples from the native audio passes. | +| `recomp-research` | Reverse-engineer retail behaviour with the skate3recomp research build: find code, write guarded trace hooks, drive the game, analyse traces, port in your own words. | +| `recomp-scripted-runs` | Unattended scripted recomp sessions (pad scripts, teleport routes, background mode, screenshots), passive sessions, the trace line catalogue, recomp artefacts to discount. | +| `recomp-audio-trace` | Build the recomp research build (title update, clang toolchain) and trace which audio banks/samples retail plays. | +| `aems-port` | The native Rust port of the retail audio runtime (AEMS evaluator, voice graph, MixMap, grain bed, Splice, player and world components): workflow, oracles, specs, the console-cadence rule. | +| `audio-tuning` | Turn a "this sounds wrong" report into a measured, mechanism-level fix: state logs, headless e2e replays, recomp comparisons, world audio checks, re-exporting audio. | +| `audio-autotest` | The automated in-game check of the audio modding features (muted in the background, or audible for a listener). | +| `living-world` | NPC skaters, pedestrians, traffic and movable objects ported from retail: scope, shared design, multiplayer-ready rules, testing. | + +## Helper scripts included + +| Skill | Scripts | +|---|---| +| `conflict-resolution/tools` | `check_lines_kept.py` (lines a branch added that the merge lost), `resolve_like.py` (resolve hunks exactly like a reference branch) | +| `optimisation/tools` | `e2e_bench.sh`, `compare_runs.sh`, `timing_summary.py` (headless audio bench and identity check), `audio_timing_summary.py` (`AUDIO_TIMING` readout), `frame_log_summary.py` (`SKATE_FRAME_LOG` readout) | +| `recomp-research/tools` | `trace.py` (shared trace reader + malformed-line check), `first_pass.py` (summarise first-pass hooks), `fn.sh` (dump a recompiled function), `fnstrings.py` (strings a function references) | +| `recomp-scripted-runs/tools` | `recomp_script_run.sh` (boot, script, trace, stop), `screen_loop.ps1` (window screenshots), `stop_dev_recomp.ps1`, `make_location_script.py` (Challenge Map teleport scripts) | +| `aems-port/tools` | `e2e/` (scenario writer, render comparisons, frame levels, retail capture windows), `grec_level.py` (retail rolling-bed levels from GREC lines) | +| `audio-tuning/tools` | `check_sets_vs_trace.py`, `long_run_stats.py`, `check_bed_level.py` (world audio vs recomp traces) | +| `audio-autotest/tools` | `audio_mod_autotest.ps1` (the autotest runner) | +| `living-world/tools` | `render_glb.py` (offscreen render of a converted model) | + +More general tools referenced by the skills (map/collision inspection, regression checks with baselines, recomp code +search, vault inspection, setup equivalence) are in `tools/` of upstream PR #37 ("Tools: published research and +regression helpers"); fetch that branch if it is not merged yet. + +## Prerequisites + +- Windows, Rust (MSVC toolchain), LLVM, CMake (Visual Studio's bundled copy works), Python 3.13 (`py -3.13`), Git + Bash (for the `.sh` scripts), the GitHub CLI `gh` (for `prior-work-check`). Some scripts use numpy / Pillow. +- **Your own legally owned copy of Skate 3** (Xbox 360). Assets are converted locally by the setup; nothing from the + game is in this repo or in these skills. +- For the recomp skills: you build [skate3recomp](https://github.com/mchughalex/skate3recomp) yourself from your own + copy (including the title update the recomp's sources expect), plus research hooks (script player, background + mode, trace writer and hooks) as described in `recomp-research` and `recomp-scripted-runs`. Credit skate3recomp, + its rexglue SDK and Xenia whenever you use findings from it. skate3recomp has no licence: it is a reference to run + and read locally; never copy its code or generated code into the engine. +- Audio skills (`aems-port`, `audio-tuning`, `audio-autotest`, the audio parts of `make-mod`) describe the audio + branch (upstream PR #32 and its follow-ups); on `main` without it, those crates and switches don't exist yet. + +## Principles the skills share + +- **Retail parity, proven:** port the real mechanism from the retail code; measurements only validate. No guessed + levels, weightings or timings; label values with their source. +- **Moddable from the start:** data-driven values (retail values as defaults from setup data), stable identities a + mod can override, a mod-facing API next to the engine-facing one, cleanup when a mod is disabled. +- **Multiplayer-ready:** stable ids, serialisable events, deterministic ticks. +- **Optimise only with proof of identical behaviour.** +- **No game code or data in the repo**, and credit every project you learn from. +- **One game instance at a time** (engine or recomp), hard time limits on every automated run, and clean up + processes and temporary output afterwards. + +## Contributing + +Keep these copies free of machine-specific paths (use placeholders like ``, ``, +``) and personal details. If you change a skill in your `.claude/skills/` copy, bring the generic +part back here. diff --git a/tools/claude-skills/aems-port/SKILL.md b/tools/claude-skills/aems-port/SKILL.md new file mode 100644 index 00000000..4db4e821 --- /dev/null +++ b/tools/claude-skills/aems-port/SKILL.md @@ -0,0 +1,127 @@ +--- +name: aems-port +description: Implement or extend the native Rust port of Skate 3's retail audio runtime - AEMS patch-program evaluator, voice graph (resample, filters, gain, pan, sends), MixMap mixer, granular rolling bed, Splice, player and world components - from specs, validated against oracles and the recomp. Use when starting or continuing that port, or replacing a measured audio table with the real mechanism. +--- + +# Native AEMS / RenderWare Audio port + +The port lives on the audio branch (upstream PR #32 and follow-ups #36, #43, #44, #49): `crates/skate-audio` +(formats, evaluator, voice graph, mixer, runtime, `mixmap/`, `grain/`, `splice`, `bus/`, `player/`, `world/`) and +the game host `crates/skate-game/src/game_audio/` (`native.rs`, `player_audio.rs`, `grain_bed.rs`, +`world_sources.rs`, `npc_skaters.rs`). The published specs are in the branch's docs (`audio-specs/`): evaluator, +voice graph, MixMap, grain player, buses, world audio. Helper scripts: `tools/` next to this file. + +## Rules +- **Your own code.** Upstream PR #4 / #1 and skate3recomp have no licence: read them to understand behaviour, then + write the code from specs. Constants, offsets and opcode numbers are facts and may be used. CC0 sources (for + example dbalatoni13/nfsmw, mitsevox/tw2004) may be used. Repos without a licence (for example the Burnout decomp) + are reference only. +- **Credit** every project whose findings you use (name, author, link, licence, what it gave) in the docs and PR. +- **Retail parity, proven:** measure, don't guess. Every module is checked against golden data before it replaces a + measured table. Never tune by ear; every change needs a measured difference and a mechanism. No A/B env switches + for behaviour changes: prove the change with the e2e bench, then people listen in game. +- **Optimise only with proof** of identical output (skill `optimisation`). +- **Modular and moddable from the first line:** each subsystem behind a clear boundary; every sound resolved through + a content layer (retail identity -> mod override -> retail); retail values come from setup data as defaults, not + hard-coded constants; a Lua SDK surface (posting, globals, MixMap, event hooks, mod emitters; skill `make-mod`); + cleanup on mod disable. Ask "can a mod reach this?" in every design and review. +- **Multiplayer-ready:** stable identities, serialisable events, deterministic ticks with seeded RNGs. +- **Headless only for agents:** cargo build/test, Python, file reads. In-game listening is done by people. +- No game code or data in the repo: banks, WAVs and image dumps come from each user's own install at setup time. + +## Workflow +1. Extract the disc's banks once (a local script that pulls the `.abk` / `.csi` files from your install into a + gitignored folder such as `.local/audio-re/aems-banks`; check S10A slot order = WAV order). +2. Change the crate; `cargo test -p skate-audio --release` (unit tests + data-gated disc tests such as the bank census + and DSP oracle, which skip without local data). +3. **Oracles:** a local build of upstream PR #4 (its own licence terms: reference only, never committed) provides an + evaluator golden (per-op trace + RNG state log), DSP vectors and an end-to-end player render. Known PR #4 + evaluator gaps to fix locally in the oracle first: op 5 (CallFunction) sample counters never advance, ops 19/20 + (n-ary min/max) and 38 (ControlClass) missing. Compare our `aems_replay` / `mixmap_golden` example outputs with + the oracle's per script: identical / diff. +4. Whole-graph check: `examples/aems_render.rs` (RMS/peak per second, optional WAV) on real banks + WAVs. +5. MixMap: an independent reference evaluator written from the spec matched PR #4's port on 73,800 golden cells; + port its semantics exactly. +6. Grain: unit tests hold the oracle's PICK/CAND/POS vectors; `examples/board_mix_probe.rs`, + `examples/grain_bed_render.rs` (whole bed headless: starts/s, RMS, `--wav`). +7. Game: `cargo test -p skate-game --release --bin skate3rust -- game_audio` and `--no-run`. +8. Player components (`skate_audio::player`): pure `process` / `update` state machines returning + Post/Redeliver/Release commands; read the retail poster/constructor/updater with `fn.sh ` (skill + `recomp-research`) and port the words exactly. Headless checks in `player_audio.rs` tests (`--nocapture`): voice + gains through the real banks, wind level vs retail medians. +9. **End-to-end** (`tools/e2e/`): `scenarios.py` writes synthetic scenarios (or `--from-log LOG --cut A-B NAME` on a + `SKATE_AUDIO_STATE_LOG` capture of real play); ours: `E2E_DIR= cargo test -p skate-game --release + --bin skate3rust -- --ignored e2e_render` (`E2E_FPS`, `E2E_TIMING=1`, `E2E_=0` to drop a part); then + `compare.py`, `frame_levels.py` (folded dBFS + each voice group per frame), `render_diff.py A B`, + `voices_summary.py`, `bank_rate.py`, `wet_level.py`; retail windows with `retail_windows.py SESSION`. The oracle is + a reference, not ground truth: where it disagrees with retail, retail wins. +10. Retail bed data: `tools/grec_level.py ` on GREC lines (skill `recomp-scripted-runs`): straight-roll level + = A / (1 - max(|I|, Bk)) per km/h band, carve rows. Pooled medians include paused / braking / manual / grind + frames; clean straight-roll frames first. About a third of GREC rows have empty players (off board/menus). +11. Vault classes and fields: `tools/vault-inspect/` of upstream PR #37 (`vault_layout.py CLASS` prints a class's + field offsets from the disc schema, `vault_fields.py`, `find_field.py HASH...`). + +## Specs: key facts +| Area | Key facts | +|---|---| +| Evaluator | ABKC/MOIR layouts; walk every 6 blocks (31.999998 ms), newest instance first; 40 opcodes (17/18 Mux/Demux, 19/20 n-ary min/max, 37 Function, 38 ControlClass, 39 SetGlobalVariable); one shared 6-word add-with-carry RNG (zero at boot, ops 7-9); sine table generated by formula; op-27 player block | +| Voice graph | 256-frame blocks at 48 kHz; linear 16.16 resampler, <= 4x; RBJ biquads with Q = 1, bypass outside 24 Hz ... 0.999 Nyquist; 64-sample gain de-click; Send ramp to 64/65; internal channel order L C R Ls Rs LFE; output clamps to +-1, no master gain | +| MixMap | 14 slot sections of A (input x curve -> mB) / B (distance x azimuth roll-off + Doppler) / F (AHR/ADSR ducks) / C (clamped sums) / E (output sums) / G (conversions); per-instance copies; volume = mB -> Q15 (0 mB -> 32730), pitch cents, filter 25000 * 2^(c/1200) Hz, raw azimuth = pan; Master.in1-4 must be 32767 | +| Grain player | seek table maps sample -> byte offset only; per truck two players A/B (B 0.1 behind, turning/downhill layer); read position = Bezier of speed/max speed (retail's factored form); 2 voices per player, non-repeating picks of 3-4 windows in a 1.6 s / 1.5 s search window; every 256-frame block; equal-power sqrt fades; grain every 0.3 s (0.5 s above 35 km/h); a drift > 0.05 cuts the grain | +| Prior art | nfsmw `saems.c` / `aemsdef.h` official names; Burnout decomp plug-in IDs match Skate's | + +## Learned while porting +- Sample-group entry bytes 3..7 are per-channel azimuths (stereo 224/32 = -45/+45 deg, quad 224/32/160/96); byte 8 + is the first byte of the stream-offset word. +- The rebase and interface lists sit after the sample data on all banks: the runtime needs whole files. +- Retail's biquad associates its feed-forward sum by position in each group of 8; found by black-box fitting against + the oracle (`fit_*` ignored tests). Stop fitting after two failed rounds. +- The game inputs matter as much as the evaluator: for the local player PlayerPhysics.in9 and in13 are 0; + Master.in1-4 = 32767. +- Grain player recent list: a sentinel (-2, -1) plus up to 15 inserted windows; "full" = the 15th insert; a collapse + keeps the last inserted window. Pick index = trunc(((r as f32 * 2^-31) * 0.5) * count), all f32. Its generator is + the same add-with-carry as AEMS's, own instance, seeded with image constants. +- Class_Seams needs the boot utility `Start_up_Play_ctl` (`Common.abk`); without it every hit plays sample 0 of its + block. Packet sweeps: post utilities first, trigger period >= 2. +- When a component plays what retail never does, sweep its packet words one at a time on the real bank before + suspecting the evaluator. +- A graph module's class defaults (Sen0 gain, Pn21 constructor args) are in the image's plug-in descriptors. +- **First-trigger rule:** every component must sound identical on its first trigger (decode/preload before use); + test first vs second trigger renders. +- `post` log lines are not audible sound (the MixMap can hold a posted voice at about 0): judge by rendered level. +- Missing setup data logs an `error!` and those sounds stay silent: re-run setup's `audio` group rather than adding + fallbacks. +- Parallel agents on one crate: standalone copies of the crate with their own `[workspace]`, merged with + `git merge-file MAIN BASE AGENT` against a pristine base copy. + +## Frame-rate rule (console cadence) +Retail processes that run once per rendered frame target the shipped 360's ~30 fps cadence, not the recomp's +uncapped frame rate. Run them on a 30 Hz virtual cadence with interpolated inputs and prove the same output at +30/60/144/365 fps (`E2E_FPS`). On the console cadence so far: Class_Seams, the MixMap evaluation (dt 1/30), the +Jitter walk and the eEQChain clear, the body and deck posters (cooldowns in console evaluations), the owner's turn +and brake slews. Physics-side conditioners (landed latch, 4-frame holds) stay per physics step (60 Hz). Note the +recomp's frame rate whenever a recomp trace is used as a per-frame reference. + +## Validation against retail +- Recomp traces (skills `recomp-scripted-runs`, `recomp-audio-trace`); filter per-player lines to the local rider + (`local72`). +- Capture levels are in the recomp host's downmix space: compare relative levels. +- Tolerance: bit-exact where the spec says the behaviour is replay-verified; otherwise within 0.1 dB per block for + whole-voice renders. +- Before trusting a "late" voice in a recomp session, list its audio-thread stalls. + +## Diagnosing a listening report +- Find the moment in the play session's state log: state ids 100 ground, 103 takeoff/pop, 200/201 air, 300 wipeout, + 400-403 grind, 500-502 off board / carrying the board, 701/702 reset; cut windows with `scenarios.py --from-log + --cut`. +- Render them headless and read `frame_levels.py`; render the "good" moments too as controls. +- Compare with retail frames (clean GREC/GAIN/SEND/PLAY sessions); fix only what the retail evidence supports. +- Example: riderless-board contacts while off board were audible as rolling + skid; retail reports no wheel contacts + there, so the audio record now does the same (audio-only; physics untouched). + +## Order of work (how the port was staged) +1. An engine-independent crate (`#![forbid(unsafe_code)]`, no Bevy, pinned deps): formats (ABKC, MOIR, SPLC, MXB, + grains), evaluator, voice graph, mixer, MixMap, grain player. +2. Per-module unit tests plus golden vectors. +3. A Bevy host in `skate-game`: one device-paced stream. +4. Retire measured tables one at a time, each with regression-check section 2 plus a listening pass. diff --git a/tools/claude-skills/aems-port/tools/e2e/bank_rate.py b/tools/claude-skills/aems-port/tools/e2e/bank_rate.py new file mode 100644 index 00000000..938ab439 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/bank_rate.py @@ -0,0 +1,30 @@ +"""Voice starts per second and summed gain of one bank in e2e renders, per (tag, seam pattern) of the +scenario, for before/after comparisons (e.g. Class_Seams on a surface). +usage: py -3.13 bank_rate.py BANK DIR NAME [DIR ...] (a voice start = a frame where the bank has more +voices than in the frame before, counted by the increase)""" +import collections +import csv +import sys + +bank, name, dirs = sys.argv[1], sys.argv[3], [sys.argv[2]] + sys.argv[4:] +for d in dirs: + rows = list(csv.DictReader(open(f'{d}/{name}.tsv'), delimiter='\t')) + count = collections.Counter() + gain = collections.defaultdict(float) + for r in csv.reader(open(f'{d}/{name}.ours.voices.tsv'), delimiter='\t'): + if r[0] != 'frame' and r[1] == bank: + count[int(r[0])] += 1 + gain[int(r[0])] += float(r[3]) + agg = collections.defaultdict(lambda: [0, 0, 0.0]) + for f, r in enumerate(rows): + if float(r['speed']) < 2 or r['state'] != '100': + continue + k = (r['tag'], r.get('seam0', '?')) + a = agg[k] + a[0] += 1 + a[1] += max(0, count[f] - count[f - 1]) if f else count[f] + a[2] += gain[f] + print(d) + for k, (n, s, g) in sorted(agg.items(), key=lambda x: -x[1][0]): + if n >= 30: + print(f' tag {k[0]:>3} pattern {k[1]:>2}: {n / 60:5.1f} s starts {s / (n / 60):5.1f}/s mean summed gain {g / n:.4f}') diff --git a/tools/claude-skills/aems-port/tools/e2e/compare.py b/tools/claude-skills/aems-port/tools/e2e/compare.py new file mode 100644 index 00000000..2a409c5f --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/compare.py @@ -0,0 +1,146 @@ +"""End-to-end audio comparison, step 3: compare two headless renders of the same scenario +(`.ours.f32` and `.poc.f32`, raw f32, 6 channels L, R, C, LFE, Ls, Rs, 48 kHz, one +settle second first) per segment of the scenario (rows with the same state/air/grind/brake/manual +/balance/speed bucket form one segment). + +Per segment and side: RMS (dBFS of the common fold L + 0.707*C + 0.5*Ls, no output gain), +octave-band levels 63 Hz .. 16 kHz, spectral centroid, dominant pitch (autocorrelation, 60–2000 Hz) +and the 10 ms envelope's onset count. Also the lag (ms) that best aligns the two envelopes and +their correlation (rolling "sync"). + +usage: py -3.13 compare.py [DIR] [--only a,b] [--a ours] [--b poc] [--json OUT] +""" +import json +import math +import os +import sys +from pathlib import Path + +import numpy as np + +DIR = Path(os.environ.get('E2E_DIR') or Path(__file__).resolve().parents[5] / '.local' / 'audio-re' / 'e2e') +RATE = 48000 +FRAME = 800 # samples per 60 Hz frame +BANDS = [63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] + + +def load(path): + d = np.fromfile(path, dtype='= f / math.sqrt(2)) & (freqs < f * math.sqrt(2)) + bands.append(db(spec[sel].sum() / total)) + centroid = float((freqs * spec).sum() / total) + # pitch: autocorrelation of the mono signal (first 0.5 s max) + seg = mono[: min(len(mono), RATE // 2)] + seg = seg - seg.mean() + ac = np.fft.irfft(np.abs(np.fft.rfft(seg, 2 * len(seg))) ** 2)[: len(seg)] + lo, hi = RATE // 2000, RATE // 60 + pitch = float('nan') + if ac[0] > 0 and hi < len(ac): + k = lo + int(np.argmax(ac[lo:hi])) + if ac[k] / ac[0] > 0.3: + pitch = RATE / k + env = np.sqrt(np.mean(x[: len(x) // 480 * 480].reshape(-1, 480, 2) ** 2, axis=(1, 2)) + 1e-20) + e = 20 * np.log10(env) + onsets = int(np.sum((e[1:] - e[:-1]) > 6.0)) + return dict(rms=rms, bands=bands, centroid=centroid, pitch=pitch, onsets=onsets, env=e) + + +def lag(ea, eb): + n = min(len(ea), len(eb)) + if n < 20: + return float('nan'), float('nan') + a, b = ea[:n] - ea[:n].mean(), eb[:n] - eb[:n].mean() + best, bk = -2, 0 + for k in range(-30, 31): # ±300 ms in 10 ms steps + if k >= 0: + c = np.corrcoef(a[k:], b[: n - k])[0, 1] if n - k > 10 else 0 + else: + c = np.corrcoef(a[: n + k], b[-k:])[0, 1] if n + k > 10 else 0 + if c > best: + best, bk = c, k + return bk * 10.0, float(best) + + +def main(): + d = Path(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith('--') else DIR + only = set(sys.argv[sys.argv.index('--only') + 1].split(',')) if '--only' in sys.argv else None + sa = sys.argv[sys.argv.index('--a') + 1] if '--a' in sys.argv else 'ours' + sb = sys.argv[sys.argv.index('--b') + 1] if '--b' in sys.argv else 'poc' + report = {} + for tsv in sorted(d.glob('*.tsv')): + name = tsv.stem + if name.endswith('voices') or (only and name not in only): + continue + pa, pb = d / f'{name}.{sa}.f32', d / f'{name}.{sb}.f32' + if not (pa.exists() and pb.exists()): + continue + xa, xb = load(pa), load(pb) + rs = rows(tsv) + print(f'\n== {name} ({sa} vs {sb})') + print(f'{"segment":34s} {"RMS dB":>15s} {"d":>6s} {"centroid Hz":>13s} {"pitch Hz":>13s} onsets lag ms/corr octave bands d (63..16k, dB rel. total)') + report[name] = [] + for a, b, r in segments(rs): + ma, mb = metrics(xa[a * FRAME:b * FRAME]), metrics(xb[a * FRAME:b * FRAME]) + if not ma or not mb: + continue + label = f'{a / 60:4.1f}-{b / 60:4.1f}s st{int(r["state"])} {r["speed"] * 3.6:3.0f}kmh' + \ + ('' if not r['grinding'] else ' grind') + (' brake' if r['brake'] else '') + (' manual' if r['balance'] else '') + \ + (' air' if r['air'] else '') + lg, corr = lag(ma['env'], mb['env']) + bd = ' '.join(f'{x - y:+5.1f}' for x, y in zip(ma['bands'], mb['bands'])) + print(f'{label:34s} {ma["rms"]:6.1f} / {mb["rms"]:6.1f} {ma["rms"] - mb["rms"]:+6.1f} {ma["centroid"]:5.0f} / {mb["centroid"]:5.0f} ' + f'{ma["pitch"]:5.0f} / {mb["pitch"]:5.0f} {ma["onsets"]:3d}/{mb["onsets"]:3d} {lg:+5.0f}/{corr:4.2f} {bd}') + report[name].append(dict(segment=label, rms=[ma['rms'], mb['rms']], centroid=[ma['centroid'], mb['centroid']], + pitch=[ma['pitch'], mb['pitch']], onsets=[ma['onsets'], mb['onsets']], lag=lg, corr=corr, + bands=[ma['bands'], mb['bands']])) + if '--json' in sys.argv: + Path(sys.argv[sys.argv.index('--json') + 1]).write_text(json.dumps(report, indent=1)) + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/aems-port/tools/e2e/frame_levels.py b/tools/claude-skills/aems-port/tools/e2e/frame_levels.py new file mode 100644 index 00000000..1d78c078 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/frame_levels.py @@ -0,0 +1,43 @@ +"""Per-frame level of an e2e render next to the scenario's state: the folded stereo RMS (dBFS, the +title's 0.707/0.5/0.5 fold) and the summed per-voice gain of each voice group (grain bed, wheel +spin, splice one-shots, each AEMS bank), so you can see which voice is audible when. +usage: py -3.13 frame_levels.py DIR NAME [FROM TO STEP] (frames of the scenario; default all, step 3) +Reads DIR/NAME.tsv (scenario), DIR/NAME.ours.f32 (6 ch, PoC order L R C LFE Ls Rs), DIR/NAME.ours.voices.tsv.""" +import csv +import math +import struct +import sys +from collections import defaultdict +from pathlib import Path + +d, name = Path(sys.argv[1]), sys.argv[2] +a, b, step = (int(x) for x in sys.argv[3:6]) if len(sys.argv) > 5 else (0, 10 ** 9, 3) +rows = list(csv.DictReader(open(d / f'{name}.tsv'), delimiter='\t')) +raw = (d / f'{name}.ours.f32').read_bytes() +n = len(raw) // 24 +pcm = struct.unpack(f'<{n * 6}f', raw[:n * 24]) +SETTLE = 60 +spf = 800 # 48000 / 60 +voices = defaultdict(lambda: defaultdict(float)) +groups = set() +for r in csv.DictReader(open(d / f'{name}.ours.voices.tsv'), delimiter='\t'): + bank = r['bank'] + key = 'grain' if bank.startswith('grain') or bank == 'rocket' else ('splice' if bank.startswith('splice:') else bank) + key = 'direct' if key == '?' else key # Splice / stream voices as the mixer lists them + voices[int(r['frame'])][key] += float(r['gain']) + groups.add(key) +groups = sorted(groups) +print('frame state air wh speed dBFS ' + ' '.join(f'{g[:10]:>10}' for g in groups)) +for f in range(a, min(b, len(rows)), step): + s0 = (SETTLE + f) * spf + acc = 0.0 + for k in range(s0, min(s0 + spf, n)): + L, R, C, _, Ls, Rs = pcm[6 * k:6 * k + 6] + lo = 0.707 * L + 0.5 * C + 0.5 * Ls + ro = 0.707 * R + 0.5 * C + 0.5 * Rs + acc += lo * lo + ro * ro + rms = math.sqrt(acc / (2 * spf)) if acc > 0 else 0.0 + db = 20 * math.log10(rms) if rms > 0 else -120.0 + r = rows[f] + print(f"{f:5d} {r['state']:>5} {r['air']:>3} {r['wheels']:>3} {float(r['speed']):6.2f} {db:6.1f} " + + ' '.join(f'{voices[f].get(g, 0):10.4f}' for g in groups)) diff --git a/tools/claude-skills/aems-port/tools/e2e/render_diff.py b/tools/claude-skills/aems-port/tools/e2e/render_diff.py new file mode 100644 index 00000000..a7bc59d6 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/render_diff.py @@ -0,0 +1,36 @@ +"""Compare two directories of e2e renders (`.ours.f32`, interleaved 6-channel f32 at 48 kHz): +per scenario bit-identical / max |difference| / RMS level of each (dBFS over the six channels) and +the level change. Headless; reads files only. + +usage: py -3.13 render_diff.py DIR_A DIR_B +""" +import math +import sys +from pathlib import Path + +import numpy as np + + +def rms_db(x): + r = float(np.sqrt(np.mean(np.square(x, dtype=np.float64)))) if x.size else 0.0 + return 20 * math.log10(r) if r > 0 else -200.0 + + +def main(): + a, b = Path(sys.argv[1]), Path(sys.argv[2]) + print(f"{'scenario':14} {'same':>5} {'max|d|':>10} {'A dBFS':>8} {'B dBFS':>8} {'B-A dB':>7}") + for fa in sorted(a.glob('*.ours.f32')): + fb = b / fa.name + if not fb.exists(): + continue + xa = np.fromfile(fa, dtype='5} {d:10.3g} {la:8.2f} {lb:8.2f} {lb - la:+7.2f}") + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/aems-port/tools/e2e/retail_windows.py b/tools/claude-skills/aems-port/tools/e2e/retail_windows.py new file mode 100644 index 00000000..e37786b2 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/retail_windows.py @@ -0,0 +1,142 @@ +"""End-to-end check against retail: straight-rolling windows of a recomp session's capture +(audio.f32 = the recomp host's stereo fold 0.4·(L + Ls + 0.5·C) per side, CAPTURE-aligned) against +our / the PoC's renders folded the same way. + +Windows: SKATEB samples with the four wheels down (no truck/deck contact), the horizontal deck +speed steady (±12 %) for >= 1.0 s and no SPLC line from the board contacts inside (collision-manager +one-shots stay in: they ride along with rolling in retail). Per +5 km/h bin: median RMS (dBFS), octave bands (dB rel. total) and the standing-still floor (speed +< 0.3 m/s windows: ambience + emitters, what the capture holds besides the board). + +usage: py -3.13 retail_windows.py SESSION_DIR [E2E_DIR] +""" +import math +import os +import sys +from bisect import bisect_left +from collections import defaultdict +from pathlib import Path + +import numpy as np + +RATE = 48000 +# SPLC callers inside SFXObj_Contacts (pops, landings, touchdowns, foot taps): a window holding one +# of these is not plain rolling. Collision-manager one-shots (824D2…) stay in (they ride along). +CONTACT_CALLERS = ('824B8', '824B9', '824BA', '824BB') +BANDS = [63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] + + +def load_trace(path): + sk, splc, caps = [], [], [] + # SKATEB interleaves every skater's board: the player's is the one logged most. + from collections import Counter + boards = Counter(l.split('\t')[2] for l in path.open(encoding='utf-8', errors='replace') if l.startswith('SKATEB ') and l.count('\t') > 3) + player = boards.most_common(1)[0][0] if boards else None + for raw in path.open(encoding='utf-8', errors='replace'): + f = raw.rstrip('\n').split('\t') + if len(f) < 3: + continue + try: + ms = float(f[1]) + except ValueError: + continue + if f[0] == 'SKATEB' and len(f) >= 6: + if f[2] != player: + continue + try: + v = [float(x) for x in f[5].split()] + sk.append((ms, f[3], math.hypot(v[0], v[2]))) + except (ValueError, IndexError): + pass + elif f[0] == 'SPLC' and len(f) >= 5 and f[4][:5] in CONTACT_CALLERS: + splc.append(ms) + elif f[0] == 'CAPTURE': + try: + caps.append((ms, int(f[2]))) + except ValueError: + pass + return sk, sorted(splc), caps + + +def capture(session, caps, a, b): + ms = np.array([c[0] for c in caps]) + frames = np.array([c[1] for c in caps]) + fa, fb = (int(np.interp(t, ms, frames)) for t in (a, b)) + data = np.memmap(session / 'audio.f32', dtype='= f / math.sqrt(2)) & (freqs < f * math.sqrt(2))].sum() / total, 1e-20)) for f in BANDS] + return rms, bands + + +def windows(sk, splc, min_s=1.0): + out = [] + i = 0 + while i < len(sk): + ms, contacts, v = sk[i] + if contacts[:4] != '1111' or contacts[4:] != '000': + i += 1 + continue + j = i + while j + 1 < len(sk) and sk[j + 1][1] == contacts and abs(sk[j + 1][2] - v) <= 0.12 * max(v, 0.3) and sk[j + 1][0] - sk[j][0] < 400: + j += 1 + a, b = ms, sk[j][0] + if b - a >= min_s * 1000: + k = bisect_left(splc, a) + if not (k < len(splc) and splc[k] <= b): + out.append((a, b, float(np.mean([s[2] for s in sk[i:j + 1]])))) + i = j + 1 + return out + + +def fold_capture(path): + d = np.fromfile(path, dtype=' 2 else Path(os.environ.get('E2E_DIR') or Path(__file__).resolve().parents[5] / '.local' / 'audio-re' / 'e2e') + sk, splc, caps = load_trace(session / 'trace.tsv') + ws = windows(sk, splc) + bins = defaultdict(list) + for a, b, v in ws: + x = capture(session, caps, a, b) + if len(x) < RATE // 2: + continue + kmh = v * 3.6 + key = 0 if kmh < 1 else int(round(kmh / 5) * 5) + bins[key].append(metrics(x)) + print(f'{session.name}: {len(ws)} straight four-wheel windows') + print('bin km/h n retail RMS p50 (p10..p90) octave bands p50 (63..16k)') + retail = {} + for k in sorted(bins): + r = sorted(m[0] for m in bins[k]) + b = np.median(np.array([m[1] for m in bins[k]]), axis=0) + retail[k] = (r[len(r) // 2], b) + print(f'{k:4d} {len(r):3d} {r[len(r) // 2]:6.1f} ({r[len(r) // 10]:6.1f}..{r[(len(r) * 9) // 10]:6.1f}) ' + ' '.join(f'{x:5.1f}' for x in b)) + print('\nrenders in the capture fold (straight roll scenarios, after the settle second):') + for name, kmh in (('roll10', 10), ('roll20', 20), ('roll30', 30), ('roll45', 45)): + for side in ('ours', 'poc'): + p = e2e / f'{name}.{side}.f32' + if not p.exists(): + continue + rms, bands = metrics(fold_capture(p)) + ref = retail.get(kmh) + delta = f'{rms - ref[0]:+5.1f} dB vs retail' if ref else 'no retail bin' + bd = ' '.join(f'{x - y:+5.1f}' for x, y in zip(bands, ref[1])) if ref else '' + print(f'{name:7s} {side:5s} RMS {rms:6.1f} ({delta}) bands d {bd}') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/aems-port/tools/e2e/scenarios.py b/tools/claude-skills/aems-port/tools/e2e/scenarios.py new file mode 100644 index 00000000..376e0244 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/scenarios.py @@ -0,0 +1,137 @@ +"""End-to-end audio comparison, step 1: write the scripted per-frame player situations both stacks +render (ours: `cargo test -p skate-game --release --bin skate3rust -- e2e_render --ignored`, the +PoC: its `e2e_render` probe). One TSV per scenario in OUT (default .local/audio-re/e2e/), one row +per 60 Hz frame, columns: + + frame speed turn wheels(mask, bit i = wheel i down) tag air air_time to_land jump_height jv + grinding family grind_tag brake manual balance scorable push slope tilt slip feet state + +speed m/s along +x; tag = the wheels' 7-bit audio surface tag; air = in known air; jv = jump +velocity delta (m/s, Air+112 y, written while the air flag 440 holds); scorable = EScorableID (-1 +none, 128 ollie); state = the physical state id (100 ground, 201 known air, 400 grind, 101 slide). +usage: py -3.13 scenarios.py [OUT] + py -3.13 scenarios.py --from-log LOG --cut A-B NAME [OUT] + --from-log: cut a real-play log (`SKATE_AUDIO_STATE_LOG`, same columns plus ms and positions) to a + scenario: A-B in seconds of the log's own clock (column ms / 1000); frames renumbered from 0. + --cut rA-B NAME: rows A..B of the log instead (its clock restarts at map loads). + --cut rA-B NAME: rows A..B of the log instead (its clock restarts at map loads). +""" +import math +import os +import sys +from pathlib import Path + +OUT = Path(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('--') else (os.environ.get('E2E_DIR') or Path(__file__).resolve().parents[5] / '.local' / 'audio-re' / 'e2e')) +COLS = ['frame', 'speed', 'turn', 'wheels', 'tag', 'air', 'air_time', 'to_land', 'jump_height', 'jv', 'grinding', + 'family', 'grind_tag', 'brake', 'manual', 'balance', 'scorable', 'push', 'slope', 'tilt', 'slip', 'feet', 'state'] +CONCRETE = 3 # concrete (rolling grain asphalt/concrete family; both stacks resolve the tag themselves) +METAL = 9 +LEDGE = 3 + + +def row(**kw): + r = dict(speed=0.0, turn=0.0, wheels=15, tag=CONCRETE, air=0, air_time=0.0, to_land=0.0, jump_height=0.0, jv=0.0, + grinding=0, family=0, grind_tag=0, brake=0, manual=0, balance=0, scorable=-1, push=0, slope=0.0, + tilt=0.0, slip=0.0, feet=3, state=100) + r.update(kw) + return r + + +def ramp(frames, a, b): + return [a + (b - a) * i / max(1, frames - 1) for i in range(frames)] + + +def scenarios(): + out = {} + for kmh in (10, 20, 30, 45): + out[f'roll{kmh}'] = [row(speed=kmh / 3.6) for _ in range(6 * 60)] + # Carve at 20 km/h: turn input sine ±0.8, period 2 s, after 1 s straight. + out['carve20'] = [row(speed=20 / 3.6, turn=0.0 if f < 60 else 0.8 * math.sin(2 * math.pi * (f - 60) / 120)) for f in range(6 * 60)] + # Speed sweep 0 → 40 km/h over 6 s then back (rolling sync / pitch tracking). + sweep = ramp(360, 0.0, 40 / 3.6) + ramp(240, 40 / 3.6, 0.0) + out['sweep'] = [row(speed=v) for v in sweep] + # Ollie at 20 km/h: 1.5 s roll, takeoff (ollie scorable, jump velocity), 0.7 s air, land, 2 s roll. + seq = [row(speed=20 / 3.6) for _ in range(90)] + air = 42 + for i in range(air): + t = i / 60 + seq.append(row(speed=20 / 3.6, wheels=0, air=1, air_time=t, to_land=max(0.0, air / 60 - t), jump_height=0.6, + jv=3.2 if i == 0 else 0.0, scorable=128, state=201)) + seq += [row(speed=20 / 3.6) for _ in range(120)] + out['ollie20'] = seq + # Rail grind (metal, family 1 = 50-50) and ledge grind (concrete, family 0) at 6 m/s for 2.5 s. + for name, tag, fam in (('grind_metal', METAL, 1), ('grind_ledge', LEDGE, 0)): + seq = [row(speed=6.0) for _ in range(60)] + seq += [row(speed=6.0, wheels=0, grinding=1, family=fam, grind_tag=tag, state=400) for _ in range(150)] + seq += [row(speed=6.0) for _ in range(60)] + out[name] = seq + # Manual at 15 km/h: back wheels only, balance flag. + seq = [row(speed=15 / 3.6) for _ in range(60)] + seq += [row(speed=15 / 3.6, wheels=0b1100, balance=1, state=100) for _ in range(150)] + seq += [row(speed=15 / 3.6) for _ in range(60)] + out['manual15'] = seq + # Foot brake from 20 km/h to 2 km/h over 2.5 s. + seq = [row(speed=20 / 3.6) for _ in range(60)] + seq += [row(speed=v, brake=1) for v in ramp(150, 20 / 3.6, 2 / 3.6)] + seq += [row(speed=2 / 3.6) for _ in range(30)] + out['brake20'] = seq + # Powerslide at 20 km/h: lateral slip, tilt, slide state. + seq = [row(speed=20 / 3.6) for _ in range(60)] + seq += [row(speed=v, slip=0.8, tilt=0.25, state=101) for v in ramp(90, 20 / 3.6, 8 / 3.6)] + seq += [row(speed=8 / 3.6) for _ in range(60)] + out['slide20'] = seq + return out + + +# Optional real-play columns (state_log.rs, appended 2026-10-02): copied when the log has them. +EXTRA = ['grind_impact', 'deck_impact', 'deck_tag', 'foot_y0', 'foot_y1', 'foot_xz0', 'foot_xz1', + 'seam0', 'seam3', 'wheel_x', 'wheel_z', 'heading', 'lines', 'foot_down', 'foot_tag_a', 'foot_tag_b', 'hands', + 'strength', 'foot_vy_a', 'foot_vy_b', 'step', 'body', 'limb', 'slide', 'deck_up', 'deck_contact', + # 2026-10-03 (session review #1 / #10): the push plant, flags, deck spin, body regions. + 'plant', 'stroke', 'deck_spin', 'spin_x', 'spin_y', 'bail', 'bail_end', 'held', 'offboard_air', 'footplant', + 'revert', 'soft', 'face'] + [f'{k}{i}' for k in ('rimp', 'rslide', 'rtag') for i in range(6)] + [ + # the bridge's speed graph input: |COM v| (logs since 2026-10-03), else the COM positions. + 'com_speed', 'com_x', 'com_y', 'com_z'] + + +def from_log(log, cut, name, out): + lines = Path(log).read_text().splitlines() + cols = lines[0].split('\t') + rows = [dict(zip(cols, l.split('\t'))) for l in lines[1:] if l.count('\t') == len(cols) - 1] + if cut.startswith('r'): + # rA-B: rows A..B (0-based, inclusive), for logs whose clock restarts at map loads. + a, b = (int(x) for x in cut[1:].split('-')) + rows = rows[a:b + 1] + else: + a, b = (float(x) for x in cut.split('-')) + t0 = float(rows[0]['ms']) + rows = [r for r in rows if a <= (float(r['ms']) - t0) / 1000 <= b] + out.mkdir(parents=True, exist_ok=True) + cols_out = COLS + [c for c in EXTRA if c in cols] + with (out / f'{name}.tsv').open('w', newline='\n') as f: + f.write('\t'.join(cols_out) + '\n') + for i, r in enumerate(rows): + r['frame'] = str(i) + f.write('\t'.join(r[c] for c in cols_out) + '\n') + print(name, len(rows), 'frames from', log) + + +def main(): + if '--from-log' in sys.argv: + i = sys.argv.index('--from-log') + log, cut, name = sys.argv[i + 1], sys.argv[sys.argv.index('--cut') + 1], sys.argv[sys.argv.index('--cut') + 2] + rest = [x for x in sys.argv[1:] if x not in (log, cut, name, '--from-log', '--cut')] + from_log(log, cut, name, Path(rest[0]) if rest else OUT) + return + OUT.mkdir(parents=True, exist_ok=True) + for name, rows in scenarios().items(): + with (OUT / f'{name}.tsv').open('w', newline='\n') as f: + f.write('\t'.join(COLS) + '\n') + for i, r in enumerate(rows): + r['frame'] = i + f.write('\t'.join(f'{r[c]:.6f}' if isinstance(r[c], float) else str(r[c]) for c in COLS) + '\n') + print(name, len(rows), 'frames') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/aems-port/tools/e2e/voices_summary.py b/tools/claude-skills/aems-port/tools/e2e/voices_summary.py new file mode 100644 index 00000000..2d952df1 --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/voices_summary.py @@ -0,0 +1,20 @@ +"""Per-frame voice gains by bank from an e2e render's .ours.voices.tsv. +usage: py -3.13 voices_summary.py VOICES_TSV [FROM TO STEP] (frames of the scenario).""" +import csv +import sys +from collections import defaultdict + +rows = list(csv.DictReader(open(sys.argv[1]), delimiter='\t')) +a, b, step = (int(x) for x in sys.argv[2:5]) if len(sys.argv) > 4 else (0, 10 ** 9, 6) +by = defaultdict(lambda: defaultdict(float)) +banks = set() +for r in rows: + f = int(r['frame']) + key = r['bank'] if not r['bank'].startswith('splice:') else 'splice' + key = 'grain' if r['slot'].startswith('grain') else key + by[f][key] += float(r['gain']) + banks.add(key) +banks = sorted(banks) +print('frame ' + ' '.join(f'{k[:14]:>14}' for k in banks)) +for f in range(a, min(b, max(by) + 1 if by else 0), step): + print(f'{f:5d} ' + ' '.join(f'{by[f].get(k, 0):14.4f}' for k in banks)) diff --git a/tools/claude-skills/aems-port/tools/e2e/wet_level.py b/tools/claude-skills/aems-port/tools/e2e/wet_level.py new file mode 100644 index 00000000..66472dcf --- /dev/null +++ b/tools/claude-skills/aems-port/tools/e2e/wet_level.py @@ -0,0 +1,38 @@ +"""The bus (wet) contribution of an e2e render: RMS of (wet render − dry render) against the dry RMS, over +the scenario's rolling frames, folded to stereo with the title's 0.707/0.5/0.5 table. +usage: py -3.13 wet_level.py DRY_DIR WET_DIR NAME [WET_DIR ...]""" +import csv +import math +import struct +import sys + +dry_dir, name, wets = sys.argv[1], sys.argv[3], [sys.argv[2]] + sys.argv[4:] + + +def load(d): + raw = open(f'{d}/{name}.ours.f32', 'rb').read() + return struct.unpack(f'<{len(raw) // 4}f', raw) + + +rows = list(csv.DictReader(open(f'{dry_dir}/{name}.tsv'), delimiter='\t')) +dry = load(dry_dir) +SETTLE, SPF = 60, 800 +frames = [f for f, r in enumerate(rows) if float(r['speed']) > 2 and r['state'] == '100'] + + +def fold(p, k): + L, R, C, _, Ls, Rs = p[6 * k:6 * k + 6] + return 0.707 * L + 0.5 * C + 0.5 * Ls, 0.707 * R + 0.5 * C + 0.5 * Rs + + +for d in wets: + wet = load(d) + n = min(len(wet), len(dry)) // 6 + ed = ew = 0.0 + for f in frames: + for k in range((SETTLE + f) * SPF, min((SETTLE + f + 1) * SPF, n)): + dl, dr = fold(dry, k) + wl, wr = fold(wet, k) + ed += dl * dl + dr * dr + ew += (wl - dl) ** 2 + (wr - dr) ** 2 + print(f'{d}: dry {10 * math.log10(ed / (2 * SPF * len(frames))):.1f} dBFS, bus part {10 * math.log10(ew / ed):+.1f} dB re dry') diff --git a/tools/claude-skills/aems-port/tools/grec_level.py b/tools/claude-skills/aems-port/tools/grec_level.py new file mode 100644 index 00000000..a4fb523c --- /dev/null +++ b/tools/claude-skills/aems-port/tools/grec_level.py @@ -0,0 +1,92 @@ +"""Retail grain-bed levels from GREC lines (recomp hook on sub_824C6BD8, local player, once per update). + +usage: py -3.13 grec_level.py [--turn-max 0.02] +GREC fields (tab-separated): owner | truck0 "A: gain pitch pos active | B: gain pitch pos active | running" | +truck1 (same) | owner floats +1160 I(turn intensity) +1164 +1168 Bk(brake slew) +1508 downhill +1456 +1464 ++1028 +1032 +1152 +1156 | state: +204 turn input, +208 ground speed, +200 wheels, +332 air (byte), ++336 brake (hex word), +340 balance (hex word), +620 material. +Straight roll = |turn input| <= --turn-max, air 0, wheels > 0, truck running, I and Bk ~ 0. For each speed band +(km/h) prints: frames, gain A / gain B / pitch A of the running truck (median, p10, p90), and +level(1) = gain A / (1 - max(|I|, Bk)): the quantity the native port's bed gain A must match. Also a carve +section (|turn input| > 0.3): B vs turn and A dip, and a manual check is left to the caller. +""" +import sys +from collections import defaultdict + + +def q(values, p): + v = sorted(values) + return v[min(len(v) - 1, int(len(v) * p))] if v else float('nan') + + +def parse(line): + f = line.rstrip('\r\n').split('\t') + if len(f) < 7 or f[0] != 'GREC': + return None + trucks = [] + for t in (f[3], f[4]): + parts = [p.strip() for p in t.split('|')] + if len(parts) != 3: + return None + a, b = parts[0].split(), parts[1].split() + if len(a) != 4 or len(b) != 4 or '-' in a[0]: + return None + trucks.append(dict(a_gain=float(a[0]), a_pitch=float(a[1]), a_on=int(a[3]), b_gain=float(b[0]), + b_pitch=float(b[1]), b_on=int(b[3]), running=int(parts[2]))) + o = [float(x) for x in f[5].split()] + s = f[6].split() + if len(o) != 10 or len(s) != 7: + return None + return dict(ms=float(f[1]), trucks=trucks, I=o[0], Bk=o[2], downhill=o[3], turn=float(s[0]), speed=float(s[1]), + wheels=int(s[2]), air=int(s[3])) + + +def main() -> None: + path = sys.argv[1] + turn_max = float(sys.argv[sys.argv.index('--turn-max') + 1]) if '--turn-max' in sys.argv else 0.02 + straight = defaultdict(lambda: defaultdict(list)) + carve = defaultdict(list) + n = bad = 0 + for raw in open(path, encoding='utf-8', errors='replace'): + if not raw.startswith('GREC\t'): + continue + r = parse(raw) + if r is None: + bad += 1 + continue + n += 1 + running = [t for t in r['trucks'] if t['running']] + if not running or r['air'] or r['wheels'] == 0: + continue + t = running[0] + kmh = r['speed'] * 3.6 + band = int(kmh // 5) * 5 + if abs(r['turn']) <= turn_max and abs(r['I']) < 0.02 and r['Bk'] < 0.02: + d = straight[band] + d['a'].append(t['a_gain']) + d['b'].append(t['b_gain']) + d['pitch'].append(t['a_pitch']) + d['level'].append(t['a_gain'] / max(1e-6, 1 - max(abs(r['I']), r['Bk']))) + elif abs(r['turn']) > 0.3: + carve[band].append((abs(r['turn']), abs(r['I']), t['a_gain'], t['b_gain'])) + print(f'GREC lines parsed {n}, unparsable {bad}') + print('STRAIGHT ROLL (|turn| <= %.2f, I, Bk ~ 0)' % turn_max) + print(' km/h frames gainA med [p10 p90] gainB med pitchA med level(1) med [p10 p90]') + for band in sorted(straight): + d = straight[band] + if len(d['a']) < 30: + continue + print(f" {band:3d}-{band + 5:<3d} {len(d['a']):7d} {q(d['a'], .5):.3f} [{q(d['a'], .1):.3f} {q(d['a'], .9):.3f}]" + f" {q(d['b'], .5):.3f} {q(d['pitch'], .5):.3f} {q(d['level'], .5):.3f} [{q(d['level'], .1):.3f} {q(d['level'], .9):.3f}]") + print('CARVE (|turn| > 0.3)') + print(' km/h frames |turn| med I med gainA med gainB med') + for band in sorted(carve): + rows = carve[band] + if len(rows) < 30: + continue + print(f" {band:3d}-{band + 5:<3d} {len(rows):7d} {q([x[0] for x in rows], .5):.2f} {q([x[1] for x in rows], .5):.2f}" + f" {q([x[2] for x in rows], .5):.3f} {q([x[3] for x in rows], .5):.3f}") + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/asset-setup/SKILL.md b/tools/claude-skills/asset-setup/SKILL.md new file mode 100644 index 00000000..2a89c10c --- /dev/null +++ b/tools/claude-skills/asset-setup/SKILL.md @@ -0,0 +1,107 @@ +--- +name: asset-setup +description: Convert a Skate 3 Xbox 360 ISO or extracted disc into game assets for a dev checkout, repair missing pro skaters, or re-point the assets junction. Use when assets are missing, setup failed, characters are unavailable, or the game says the assets folder is missing. +--- + +# Asset setup for a dev checkout + +You need your own legally owned copy of Skate 3 (Xbox 360 ISO, or an extracted disc folder with `default.xex`). +Converted assets are never committed. Keep where your ISO, extracted disc and current installation id live in +your own notes file. + +## How it fits together +- `PLAY.bat` -> `scripts/Launch.ps1` expects converted assets at repo-root `assets\` (a junction to + `data\installations\\assets`). +- `tools/setup.py` is the release GUI; the work is + `tools.asset_pipeline.customiser_setup.install(source, base, game_exe, report, refresh=False)`: + - `source`: an `.iso`, or `default.xex` inside an extracted disc folder. + - `base`: `\data` (gitignored). Output: `data\installations\<32-hex id>\`, published via + `data\installation.json`. + - `game_exe`: `\bin\skate3rust.exe`, which must be built first (skill `build-and-run`); setup runs it with + `--check-assets` to validate. + +## Headless run (no GUI) +```python +import sys; from pathlib import Path +ROOT = Path(r''); sys.path.insert(0, str(ROOT)) +from tools.asset_pipeline.customiser_setup import install +from tools.asset_pipeline.optional_content import summary +stage = install(Path(r'\default.xex'), ROOT/'data', ROOT/'bin'/'skate3rust.exe', lambda t: print(t, flush=True)) +print(stage, summary(stage)) +``` +Run with `py -3.13 -u` in the background; it takes several minutes. Use raw strings for Windows paths in Python +(a non-raw `"\a..."` or `"\x86"` silently becomes a control character). Afterwards: +```powershell +New-Item -ItemType Junction -Path assets -Target "\data\installations\\assets" # remove the old junction first if the id changed +bin\skate3rust.exe --assets assets --test-world --check-assets # expect SKATE_ASSETS_READY +``` + +## Refresh after a converter change +- `install(, data, bin\skate3rust.exe, report, refresh=True)` rebuilds only groups whose pipeline + fingerprint changed (`versions.changed_groups`), plus the customiser if its fingerprint changed. Check beforehand: + `changed_groups(installed(data)[1]['pipelines'], fingerprints())`. +- Close the game first: refresh publishes a NEW `data\installations\` and deletes the old one. Afterwards + re-point the junction (`rmdir assets`, then `New-Item -ItemType Junction ...` to the new id) and run + `--check-assets`. +- Then run skill `regression-check` section 1 (all maps' spawns vs baseline, `--check-assets` per map, 0 warnings). + +## Diagnosing a bad spawn (void / roof / invisible map) +- Spawn rule: maps spawn at their authored start locators; on older trees University used a fixed XZ, compact + districts (collision XZ span <= 500 m) used `ground_spawn` and cities the nearest-origin rule. "Invisible map with + working collision" = spawned under the map. +- Log: `SKATE_MAP_LOADED ... spawn=[x,y,z]`, then `REPORT_TRANSITION physics_tick:9 + requested:PhysicsGround->PhysicsAir` with no landing = falling from spawn. +- Every map's startup position and menu "default landing zone" is the baked spawn; `teleports.json` destinations are + only used when picked from the menu. +- Inspect the collision with the collision tools (`tools/collision-inspect/` from upstream PR #37, "Tools: published + research and regression helpers"; fetch that branch if it is not merged yet). They write to gitignored + `.local\collision\\`: + ```bash + py -3.13 tools/collision-inspect/map_collision.py MegaPark # extract, print bounds + the rule's spawn + py -3.13 tools/collision-inspect/analyse_spawn.py MegaPark x y z # surfaces at that XZ + flat-area centre + ``` + Nothing below the spawn height = void; a much lower surface under it = roof/platform; spawn below the street = + under the map. + +## Setup behaviour to expect (newer branches) +- The environment group also extracts the water/ocean animation table (`ocean_pca.py` -> game + `--extract-ocean-pca`, needs the current `bin\skate3rust.exe`) and the particle sprites (`particles.py` -> + `assets/private/particles/*.png`). A change to either refreshes only `environment`; afterwards re-point the + `assets` junction to the new installation. +- One `--validate-maps` process validates stock data, each map as it finishes converting, recovered maps and the + final test world (fallback: `--check-assets` per map). Warnings land in `map-status/-validation.json` and + `setup-report.json` via `validation_report.summary`. +- The character customiser runs on a background thread beside the map stage when memory allows + (`install.overlap_customiser`); log line "Preparing the character customiser alongside the maps". +- Group `audio` (audio branches): downloads vgmstream (SHA-256 checked) into `data\tools\vgmstream-cli\` and decodes + the disc's audio into `assets/private/audio/` (hundreds of MB). Optional: on failure it writes + `audio-availability.json` and the game runs silent. Dev re-export of just this group: skill `audio-tuning`. +- City stream loads skip identical duplicate copies (`skate3_streams.read_sfil(known_copies=...)`); prove a + stream-loader change with `tools/setup-equivalence/compare_streams.py` (PR #37). + +## Diagnosing invisible walls / invisible floors +- Symptom: blocked or standing on nothing visible; logs show normal states (no `physics_failed`). +- `tools/collision-inspect/collision_attrs.py ` then query the spawn/area: a surface with surface id 0 and + unit flags `0x21` (no 0x80) on a 12-triangle box from `cSim_Global.xsf` is a zone/trigger volume, not geometry. +- `tools/collision-inspect/scan_surfaceless.py` summarises every district. Only fully surfaceless meshes are + volumes; "mixed" meshes are real geometry: never filter per triangle. + +## Known failures +- "Conversion failed. See ...extract.log" right after "Extracting your ISO": the extract-xiso argument-order bug + (options must precede the image). Otherwise extract once manually with + `extract-xiso -x -d \disc ` and pass `default.xex`. +- Optional components listed as unavailable: `summary(stage)` returns them; details in `setup-report.json` (only + exists when there are warnings). +- Pro characters failing with `[Errno 2] No such file or directory` on a long path: MAX_PATH. Enable long paths + (HKLM `...\Control\FileSystem\LongPathsEnabled=1`, admin), then repair in place (next section). + +## Repair missing roster characters without a full re-run +In the current set `assets\private\customisation\sets\\`: +1. `native_roster.prepare(game, assets, set/'native-roster', set/'database/collections.json', set/'roster-work', + only=[keys])`, keys from `native-roster/complete.json` `unavailable`. +2. Remove fixed keys from `unavailable`, add them to `characters` in `complete.json`. +3. Re-record the stage receipt exactly like `customiser_cache.stage()`: `receipt(set, all files under + OUTPUTS['roster'])` into `roster-complete.json`, keeping its `version`/`source`. Otherwise a later refresh sees the + stage as damaged. +4. Check `customiser_cache.complete(set)` is True, `summary(install)` is empty, and `--check-assets` passes. Back up + the two JSON files first. diff --git a/tools/claude-skills/audio-autotest/SKILL.md b/tools/claude-skills/audio-autotest/SKILL.md new file mode 100644 index 00000000..75fd1364 --- /dev/null +++ b/tools/claude-skills/audio-autotest/SKILL.md @@ -0,0 +1,44 @@ +--- +name: audio-autotest +description: Run the automated in-game check of the audio modding features (dev mod mods/audio-content-test in autotest mode) muted in the background, or audible while a person listens, and turn their listening verdict into PR evidence. Use after changes to audio modding (upstream PRs #36, #43), before marking such PRs ready, or when someone is ready to listen. +--- + +# Audio modding autotest + +Needs an audio branch that has the dev mod `mods/audio-content-test` with its `autotest` setting, a built +`bin\skate3rust.exe`, and converted assets (skill `asset-setup`) including the DownTown map. + +## Pieces +- Runner: `tools/audio_mod_autotest.ps1` next to this file. Its header comment is the full spec. + `-Pass on|off|both` (on = dev mod with autotest, off = mod disabled, asserts retail), `-Audible`, `-Worktree` + (default: the checkout the script sits in), `-StepSeconds/-GapSeconds`, `-WaitMinutes`, `-Map` (DownTown). +- The mod's autotest code: `mods/audio-content-test` (setting `autotest`), logs `AUTOTEST ok|fail +
`. Mod ON on DownTown: 16 steps, about 35 checks; the runner answers its hot-swap requests (edits / + reverts the copy's `audio.json`). Mod OFF asserts retail (map audio `["retail"]`, the retail crossfade, the + install's project count, no mod lines). +- Keep a plain-language list of what each step should sound like (for the listener) beside the mod, in sync with it. +- Output: `.local/autotest/runs/-/` (`stderr.txt` = game log, `results.txt`). Exit 0 ok, 1 failed check, + 2 not launched (busy), 3 setup error. + +## Muted run (any time no game runs) +`powershell -File .claude\skills\audio-autotest\tools\audio_mod_autotest.ps1 -Worktree -Pass both`. +Minimised window, `--mute`, 5 s steps. It waits while another skate3 / skate3rust process runs or +`.local\GAME_LOCK.txt` exists, and writes and removes only its own lock. One game instance at a time. + +## Audible run (a person listens) +1. Only when the listener says they are ready, and check they can see and hear the game. +2. `-Audible`: 15 s steps, 3 s quiet gap, compact top-left HUD (step, name, seconds left, what to listen for); the red + "MUTED TEST" label shows only on muted runs. Point the listener to the step list. +3. Write the listener's verdict down word for word; in PRs, quote it exactly rather than summarising. + +## Design rules learned the hard way +- Every step needs its own clearly different sound; one shared beep is too confusing. +- HUD rows must fit on one line each; top left, small. +- Explain what a step should look like, including camera moves. The camera is parked at the Baby_Cry_1 emitter for + steps 1-3 only and must come back on timeout and at stop (a parked camera kept the emitter playing after a run). +- Steps long enough to listen; silent read-back checks short. +- Known flaky checks: the synthetic ollie checks (`F11_landing_seen`, `D5_pop_seen`, `tags_after_resubscribe`) + sometimes fail because the ollie ends in a grind; 3 s steps are too short for them. +- Test content can create artefacts: six identical cars beat against each other and sounded like a lawn mower. + Separate same-model emitters (rings, detune) before blaming the engine, and never claim retail parity from a + guess. diff --git a/tools/claude-skills/audio-autotest/tools/audio_mod_autotest.ps1 b/tools/claude-skills/audio-autotest/tools/audio_mod_autotest.ps1 new file mode 100644 index 00000000..1b3b65d8 --- /dev/null +++ b/tools/claude-skills/audio-autotest/tools/audio_mod_autotest.ps1 @@ -0,0 +1,240 @@ +# Automated in-game check of the audio modding features (PRs #36 + #43) with the dev mod +# mods/audio-content-test and its `autotest` setting: nobody presses keys. +# +# audio_mod_autotest.ps1 [-Worktree ] [-Pass on|off|both] +# [-Audible] [-StepSeconds N] [-GapSeconds N] [-TimeoutSeconds N] [-WaitMinutes 30] +# +# Pass "on": DownTown, the dev mod enabled with autotest on (a copy of the mod in a run folder and a +# private SKATE3_MOD_SETTINGS folder, so neither the checkout's mod nor your saved mod +# settings are touched). The mod logs one `AUTOTEST ok|fail
` line per check; +# this script answers its hot-swap requests (edits / reverts the copy's audio.json) and stops +# the game after `AUTOTEST done`. +# Pass "off": DownTown, the dev mod present but disabled: asserts retail (map audio ["retail"], the +# retail crossfade, no overlay, no mod log lines, the install's project count). +# Default: muted (--mute), window minimised, 5 s steps, no gaps. -Audible: unmuted, a normal window, +# 15 s steps with a 3 s quiet gap before each (the HUD counts down: "Next in 3 s: ..." then +# "3/16 9 s left" and what to listen for). -StepSeconds / -GapSeconds override either; the +# hard timeout follows from them. Silent read-back steps (F5 global, seed, Csis, hot swap) always run +# 3 s (mod setting autotest_quiet_step) after a gap of at most 1 s. The camera is parked at DownTown's +# Baby_Cry_1 emitter only for steps 1-3 (the HUD says so) and returns to the skater near the end of +# step 3 (check camera_back), on a step timeout, at the end and when the mod stops. +# The run stops early when the mod fails (a Lua error stops it) or a step times out in the mod. +# Launch rules: one game at a time. It does not start while any skate3 / skate3rust runs, or while +# .local\GAME_LOCK.txt exists (other tools can write it to claim the game): it waits (60 s checks, +# -WaitMinutes limit). It writes GAME_LOCK.txt while its game runs and deletes only its own lock. +# Output: .local\autotest\runs\-\ (stderr.txt = the game log, results.txt). +# Exit code: 0 all passes ok, 1 a check failed, 2 not launched (busy / timeout waiting), 3 setup error. +param( + [string]$Worktree = '', + [ValidateSet('on', 'off', 'both')] [string]$Pass = 'both', + [switch]$Audible, + [double]$StepSeconds = 0, + [double]$GapSeconds = -1, + [int]$TimeoutSeconds = 0, + [int]$OffSeconds = 25, + [int]$WaitMinutes = 30, + [string]$Map = 'DownTown' +) +$ErrorActionPreference = 'Stop' +$ModId = 'dev-audio-content-test' +if ($StepSeconds -le 0) { $StepSeconds = if ($Audible) { 15 } else { 5 } } +if ($GapSeconds -lt 0) { $GapSeconds = if ($Audible) { 3 } else { 0 } } +$StepCount = 16 +if ($TimeoutSeconds -le 0) { $TimeoutSeconds = [int](90 + $StepCount * ($StepSeconds + $GapSeconds) + 40) } + +# The main checkout: this script lives in
\.claude\skills\audio-autotest\tools (or anywhere below a +# checkout with Cargo.toml at its root). Runs and the lock go to
\.local. +$main = $PSScriptRoot +while ($main -and -not (Test-Path -LiteralPath (Join-Path $main 'Cargo.toml'))) { $main = Split-Path $main -Parent } +if (-not $main) { Write-Host 'run this script from inside a checkout'; exit 3 } +if (-not $Worktree) { $Worktree = $main } +$Worktree = (Resolve-Path -LiteralPath $Worktree).Path +$exe = Join-Path $Worktree 'bin\skate3rust.exe' +$modSrc = Join-Path $Worktree 'mods\audio-content-test' +$assets = Join-Path $Worktree 'assets' +if (-not (Test-Path -LiteralPath $exe)) { Write-Host "no game at $exe"; exit 3 } +if (-not (Test-Path -LiteralPath (Join-Path $modSrc 'main.lua'))) { Write-Host "no dev mod at $modSrc"; exit 3 } +$target = (Get-Item -LiteralPath $assets).Target +if ($target -is [array]) { $target = $target[0] } +if (-not $target) { $target = $assets } +$mapPath = Join-Path (Split-Path $target -Parent) "maps\$Map.skate" +if (-not (Test-Path -LiteralPath $mapPath)) { Write-Host "no map at $mapPath"; exit 3 } +$lock = Join-Path $main '.local\GAME_LOCK.txt' +$esc = [char]27 + +function Busy { + $p = Get-Process -Name skate3, skate3rust -ErrorAction SilentlyContinue + if ($p) { return "a game runs (pid $(@($p | ForEach-Object Id) -join ','))" } + if (Test-Path -LiteralPath $lock) { return "GAME_LOCK.txt exists: $((Get-Content -LiteralPath $lock -Raw).Trim())" } + return $null +} + +function Wait-Free { + $deadline = (Get-Date).AddMinutes($WaitMinutes) + while ($true) { + $why = Busy + if (-not $why) { return $true } + if ((Get-Date) -ge $deadline) { Write-Host "not launching: $why (waited $WaitMinutes min)"; return $false } + Write-Host "waiting: $why" + Start-Sleep -Seconds 60 + } +} + +function Clean([string]$line) { return ($line -replace "$esc\[[0-9;]*m", '') } + +function Run-Pass([string]$name) { + $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' + $run = Join-Path $main ".local\autotest\runs\$stamp-$name" + $mods = Join-Path $run 'mods' + $settings = Join-Path $run 'settings' + New-Item -ItemType Directory -Force -Path $mods, $settings | Out-Null + Copy-Item -LiteralPath $modSrc -Destination (Join-Path $mods 'audio-content-test') -Recurse + Get-ChildItem -LiteralPath (Join-Path $mods 'audio-content-test') -Recurse -Filter '__pycache__' -Directory | Remove-Item -Recurse -Force + $audioJson = Join-Path $mods 'audio-content-test\audio.json' + $original = [System.IO.File]::ReadAllBytes($audioJson) + $audible = $Audible -and $name -eq 'on' + if ($name -eq 'on') { + $pref = @{ enabled = $true; values = @{ autotest = $true; autotest_step = $StepSeconds; autotest_gap = $GapSeconds; autotest_muted = (-not $audible); events = $true } } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText((Join-Path $settings "$ModId.json"), $pref) + } + $err = Join-Path $run 'stderr.txt' + $out = Join-Path $run 'stdout.txt' + $argv = @('--assets', $assets, '--map', $mapPath) + if (-not $audible) { $argv += '--mute' } + + if (-not (Wait-Free)) { return @{ name = $name; launched = $false; ok = $false; run = $run } } + $env:SKATE_REPORT_CHILD = '1' + $env:SKATE3_MODS = $mods + $env:SKATE3_MOD_SETTINGS = $settings + Remove-Item Env:SKATE3_MODS_ENABLE -ErrorAction SilentlyContinue + $style = if ($audible) { 'Normal' } else { 'Minimized' } + $lockText = "autotest-agent $(Get-Date -Format s) pass=$name" + [System.IO.File]::WriteAllText($lock, $lockText) + $p = $null + $edited = $false + $answered = @{} + $started = Get-Date + $doneAt = $null + $readyAt = $null + try { + $p = Start-Process -FilePath $exe -ArgumentList ($argv | ForEach-Object { '"' + $_ + '"' }) -WorkingDirectory $Worktree -PassThru ` + -WindowStyle $style -RedirectStandardOutput $out -RedirectStandardError $err + Write-Host "pass $name`: pid $($p.Id), $(if ($audible) { 'AUDIBLE' } else { 'muted' }), steps $StepSeconds s + gaps $GapSeconds s, timeout $TimeoutSeconds s, log $err" + while (-not $p.HasExited) { + Start-Sleep -Milliseconds 250 + $elapsed = ((Get-Date) - $started).TotalSeconds + if ($elapsed -gt $TimeoutSeconds) { Write-Host "pass $name`: hard timeout $TimeoutSeconds s"; break } + $text = '' + try { + $fs = [System.IO.File]::Open($err, 'Open', 'Read', 'ReadWrite') + $sr = New-Object System.IO.StreamReader($fs) + $text = $sr.ReadToEnd(); $sr.Close() + } catch { continue } + if ($name -eq 'on' -and $text -match "mod $ModId stopped|main\.lua:\d+:") { + Write-Host "pass on: the mod failed (see the log); stopping" + break + } + if ($name -eq 'on') { + if (-not $answered.edit -and $text -match 'AUTOTEST_REQ edit_audio_json') { + $s = [System.Text.Encoding]::UTF8.GetString($original) + $s2 = $s.Replace('"volume": 0.5, "time_a"', '"volume": 0.45, "time_a"') + if ($s2 -eq $s) { Write-Host 'hot-swap edit: pattern not found in audio.json' } + [System.IO.File]::WriteAllText($audioJson, $s2) + $answered.edit = $true; $edited = $true + Write-Host "pass on: edited audio.json (int_tunnel volume 0.5 -> 0.45) at $([int]$elapsed) s" + } + if (-not $answered.revert -and $text -match 'AUTOTEST_REQ revert_audio_json') { + [System.IO.File]::WriteAllBytes($audioJson, $original) + $answered.revert = $true; $edited = $false + Write-Host "pass on: reverted audio.json at $([int]$elapsed) s" + } + if (-not $doneAt -and $text -match 'AUTOTEST done') { $doneAt = Get-Date } + if ($doneAt -and ((Get-Date) - $doneAt).TotalSeconds -ge 1.5) { break } + } else { + if (-not $readyAt -and $text -match "Map audio $Map`:") { $readyAt = Get-Date } + if ($readyAt -and ((Get-Date) - $readyAt).TotalSeconds -ge $OffSeconds) { break } + } + } + } finally { + if ($p -and -not $p.HasExited) { + [void]$p.CloseMainWindow() + if (-not $p.WaitForExit(5000)) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue; [void]$p.WaitForExit(5000) } + } + if ($edited) { [System.IO.File]::WriteAllBytes($audioJson, $original) } + if ((Test-Path -LiteralPath $lock) -and ((Get-Content -LiteralPath $lock -Raw).Trim() -eq $lockText)) { Remove-Item -LiteralPath $lock -Force } + Remove-Item Env:SKATE3_MODS, Env:SKATE3_MOD_SETTINGS -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 1 + $left = Get-Process -Name skate3rust -ErrorAction SilentlyContinue + $lines = @(Get-Content -LiteralPath $err | ForEach-Object { Clean $_ }) + return @{ name = $name; launched = $true; run = $run; lines = $lines; leftover = [bool]$left; seconds = [int]((Get-Date) - $started).TotalSeconds } +} + +# Every check the mod makes, in order (a missing one is a failure). +$expected = @('start', 'babycry_emitter', 'F6_post', 'F7_release', 'camera_back', 'F5_global_set', 'F5_global_restore', 'F8_native_siren', 'F8_native_stop', + 'F9_tuning_write', 'F9_tuning_restore', 'F10_emitter_zone', 'F10_removed', 'F11_rules_set', 'F11_landing_seen', 'F11_rules_removed', + 'D1_duck', 'D1_duck_release', 'D2_seed', 'D2_seed_release', 'D3_own_taxis', 'D3_taxis_removed', 'D4_csis', 'D4_csis_release', + 'D5_nose_rule', 'D5_pop_seen', 'D5_nose_removed', 'D6_orbit', 'D6_orbit_moving', 'D6_orbit_removed', 'tags_after_resubscribe', + 'L7_hot_swap_edit', 'L7_hot_swap_revert', 'no_restart') + +function Common([hashtable]$r, [System.Collections.ArrayList]$rows) { + $panics = @($r.lines | Where-Object { $_ -match 'panicked' }).Count + [void]$rows.Add([pscustomobject]@{ check = 'no_panic'; result = $(if ($panics -eq 0) { 'ok' } else { 'fail' }); details = "panic lines=$panics" }) + [void]$rows.Add([pscustomobject]@{ check = 'stopped_clean'; result = $(if (-not $r.leftover) { 'ok' } else { 'fail' }); details = "leftover skate3rust=$($r.leftover), $($r.seconds) s" }) + $mapLine = $r.lines | Where-Object { $_ -match "Map audio $Map`:" } | Select-Object -Last 1 + $proj = $r.lines | Where-Object { $_ -match 'native AEMS runtime on \((\d+) projects\)' } | Select-Object -Last 1 + $mapInfo = if ($mapLine) { ($mapLine -replace '^.*?Map audio ', 'Map audio ') } else { 'none' } + $projInfo = if ($proj) { ($proj -replace '^.*runtime on ', '') } else { 'none' } + return @{ map = $mapInfo; projects = $projInfo } +} + +$results = @() +$passes = if ($Pass -eq 'both') { @('on', 'off') } else { @($Pass) } +$allOk = $true +foreach ($name in $passes) { + $r = Run-Pass $name + if (-not $r.launched) { Write-Host "pass $name not launched"; exit 2 } + $rows = New-Object System.Collections.ArrayList + $c = Common $r $rows + $audible = $Audible -and $name -eq 'on' + if ($name -eq 'on') { + $seen = @{} + foreach ($l in $r.lines) { + if ($l -match "Lua \[$ModId\]: AUTOTEST (\S+) (ok|fail) ?(.*)$") { + $seen[$Matches[1]] = $true + [void]$rows.Add([pscustomobject]@{ check = $Matches[1]; result = $Matches[2]; details = $Matches[3] }) + } + } + foreach ($e in $expected) { if (-not $seen.ContainsKey($e)) { [void]$rows.Add([pscustomobject]@{ check = $e; result = 'fail'; details = 'missing (never logged)' }) } } + $loads = @($r.lines | Where-Object { $_ -match "Lua \[$ModId\]: AUTOTEST_INFO on:" }).Count + [void]$rows.Add([pscustomobject]@{ check = 'script_loaded_once'; result = $(if ($loads -eq 1) { 'ok' } else { 'fail' }); details = "on_load runs=$loads" }) + $ok = $c.map -match 'crossfade Some\("DEV_fade"\)' + [void]$rows.Add([pscustomobject]@{ check = 'map_audio_mod'; result = $(if ($ok) { 'ok' } else { 'fail' }); details = "$($c.map); $($c.projects)" }) + $cry = $r.lines | Where-Object { $_ -match 'AUDIO_EMITTER start Baby_Cry_1 .*\(native\)' } | Select-Object -First 1 + # audio.json replaces 4 banks: Baby_Cry_1 (bell), Buoy_Bell (chime), Transformer_Lrg_left_2 (triangle), water_lapping_pond (shaker). + $repl = $r.lines | Where-Object { $_ -match "mod $ModId audio content: replaced banks: 4" } | Select-Object -First 1 + [void]$rows.Add([pscustomobject]@{ check = 'babycry_map_record_started'; result = $(if ($cry -and $repl) { 'ok' } else { 'fail' }); details = $(if ($cry) { ($cry -replace '^.*AUDIO_EMITTER', 'AUDIO_EMITTER') + ' (the map record; Baby_Cry_1 is the bank audio.json replaces)' } else { 'no map-record AUDIO_EMITTER start of Baby_Cry_1' }) }) + # The HUD's countdown (logged at each step / gap start) and the camera's return. + $hud = @($r.lines | Where-Object { $_ -match "Lua \[$ModId\]: AUTOTEST_HUD \d+/\d+ .* s left" }).Count + $back = $r.lines | Where-Object { $_ -match "Lua \[$ModId\]: AUTOTEST_INFO camera back at the skater" } | Select-Object -First 1 + [void]$rows.Add([pscustomobject]@{ check = 'hud_countdown'; result = $(if ($hud -ge 16) { 'ok' } else { 'fail' }); details = "AUTOTEST_HUD step lines with a countdown=$hud (want >= 16)" }) + [void]$rows.Add([pscustomobject]@{ check = 'camera_returned'; result = $(if ($back) { 'ok' } else { 'fail' }); details = $(if ($back) { $back -replace '^.*AUTOTEST_INFO ', '' } else { 'no camera return line' }) }) + $done = $r.lines | Where-Object { $_ -match 'AUTOTEST done' } | Select-Object -Last 1 + [void]$rows.Add([pscustomobject]@{ check = 'done'; result = $(if ($done) { 'ok' } else { 'fail' }); details = $(if ($done) { $done -replace '^.*AUTOTEST ', '' } else { 'no done line (timeout?)' }) }) + } else { + $retail = $c.map -match [regex]::Escape('["retail"]') -and $c.map -match 'crossfade Some\("Main_Ambience_Crossfade_DT"\)' + [void]$rows.Add([pscustomobject]@{ check = 'map_audio_retail'; result = $(if ($retail) { 'ok' } else { 'fail' }); details = $c.map }) + $modLines = @($r.lines | Where-Object { $_ -match "Lua \[$ModId\]|mod $ModId audio content|$ModId" }).Count + [void]$rows.Add([pscustomobject]@{ check = 'no_mod_activity'; result = $(if ($modLines -eq 0) { 'ok' } else { 'fail' }); details = "lines naming the mod=$modLines" }) + $swaps = @($r.lines | Where-Object { $_ -match 'swapped the mods|restarted for the mods' }).Count + [void]$rows.Add([pscustomobject]@{ check = 'no_overlay'; result = $(if ($swaps -eq 0 -and $c.projects -match '^\(?9 projects') { 'ok' } else { 'fail' }); details = "content swaps/restarts=$swaps, $($c.projects) (install: 9)" }) + } + $fails = @($rows | Where-Object { $_.result -ne 'ok' }).Count + if ($fails) { $allOk = $false } + $table = $rows | Format-Table -AutoSize -Wrap check, result, details | Out-String -Width 400 + $summary = "pass $name`: $(@($rows).Count - $fails) ok, $fails fail ($($r.run))" + Set-Content -LiteralPath (Join-Path $r.run 'results.txt') -Value ($table + $summary) -Encoding utf8 + Write-Host $table + Write-Host $summary +} +if ($allOk) { exit 0 } else { exit 1 } diff --git a/tools/claude-skills/audio-tuning/SKILL.md b/tools/claude-skills/audio-tuning/SKILL.md new file mode 100644 index 00000000..ed2eebdb --- /dev/null +++ b/tools/claude-skills/audio-tuning/SKILL.md @@ -0,0 +1,93 @@ +--- +name: audio-tuning +description: Iterate on skate3rust game audio when someone reports a sound that is missing, late, doubled, too loud/soft or wrong - map it to the retail mechanism, measure ours against the recomp (state logs, headless e2e renders, recomp traces), fix it in the native port, and have them listen. Also re-exports audio into the dev install. Use when someone reports how the game sounds or asks to change a sound. +--- + +# Audio tuning + +On the audio branch (upstream PR #32 and follow-ups) the **native runtime is the only audio path** +(`crates/skate-audio`). Sounds are not "picked": every sound comes from retail's mechanism (AEMS programs, Splice +banks, MixMap, posters), so a wrong sound means a wrong or missing mechanism or input. Port work: skill `aems-port`. +Never commit decoded audio or copy game data into docs. Never build A/B or audition pages; measure internally, people +listen in game. Helper scripts: `tools/` next to this file (installed under `/.claude/skills/audio-tuning/`; +they find the repo root from there). + +## Where things live +- `crates/skate-audio/`: the runtime: evaluator, voice graph, MixMap, grain bed, Splice, buses, player components + (`player/{contacts,collision,rolling,footsteps,clothing,tricks,treatment,wheels,...}.rs`), world sources. +- `crates/skate-game/src/game_audio/`: + - `skate_events.rs`: `observe`, the per-frame player audio state from our physics, the state log, `AUDIO_EVENT` + lines; + - `native.rs`, `player_audio.rs`, `grain_bed.rs`, `npc_skaters.rs`, `world_sources.rs`: the game-side hosts; + - `emitters.rs` (the map's `.ems` world emitters), `random_sets.rs` (random distant one-shots with retail's + scheduler), `ambience.rs` (zone ambience beds and crossfades), `library.rs` (the audio manifest). +- `physics/animation_input.rs` (`AudioEvents` latch). +- Missing setup data logs an `error!` and those sounds stay silent: re-run setup's `audio` group rather than adding + fallbacks. + +## Iteration loop +1. **Write the report down word for word** right after it is made, while it is fresh; ask follow-up questions then. +2. **Get the session.** Play with `SKATE_AUDIO_STATE_LOG=` set (one TSV row per frame). Check it for malformed + rows first: any malformed row makes the session unusable (record a new one). The game log + (`logs\game-*.stderr.log`, strip ANSI) has `Game audio: native ...` start lines, `AUDIO_DSP path=`, `AUDIO_EVENT + ...` and `AUDIO_TIMING` (with `SKATE_AUDIO_TIMING=1`). +3. **Replay it headless** through the e2e harness (`game_audio::e2e`, `E2E_DIR` must be ABSOLUTE, `E2E_FPS=60`) and + measure per bank / per moment (voices, sum of g^2, posts, tiers) with skill `aems-port`'s `tools/e2e/`. Logged + inputs replay as logged, so a change to how an input is computed needs a NEW session. +4. **Compare with the recomp** at the same kind of moment (skills `recomp-audio-trace`, `recomp-scripted-runs`). + Filter to the local rider (`local72`). The recomp is not retail hardware: trust logic and values, not timings. +5. **Find the mechanism** that produces the difference in the recompiled code (skill `recomp-research`) and port it + (skill `aems-port`): no guessed levels or weightings. Per-frame retail processes run on the console's ~30 fps + cadence, frame-rate independent. +6. **Prove the rest is unchanged** (e2e bench byte-identical except the intended moments), run tests + (`cargo test -p skate-audio --locked`, `cargo test -p skate-game --release --bin skate3rust -- game_audio`), build, + stage, muted smoke run. One game instance at a time. +7. People listen in game; record the verdict word for word and quote it exactly in the PR. Update the docs entry. + +## Build, stage, launch +- Never stage while a game runs from `bin\`: `powershell -NoProfile -Command "if (Get-Process skate3rust + -ErrorAction SilentlyContinue) { exit 3 }"` first. From bash: `powershell.exe -NoProfile -ExecutionPolicy Bypass + -File scripts/Build.ps1`. +- Smoke runs without noise: `--mute`, a few maps (StartPark, University, DownTown, MegaPark, Industrial): expect per + map 0 panics / errors / non-finite, render ready, the `Game audio: native ...` lines, `AUDIO_DSP path=fma` on FMA + CPUs (`tools/regression-checks/smoke_maps.sh` from upstream PR #37 automates this). +- Master volume defaults to 25 %; don't raise defaults. + +## Re-export audio into the dev install (after `audio_export.py`/`audio_formats.py` changes) +Game closed first. From the repo root with `py -3.13`: +```python +import io; from pathlib import Path; from tools.asset_pipeline import asset_exports +inst = Path('data/installations') / '' +asset_exports.audio(Path(r''), inst, Path(r''), print, io.StringIO(), Path('data/tools')) +``` +A real refresh through the installer must still rebuild only the `audio` group (skill `regression-check`). + +## World audio data and checks +- **Setup data** (`audio_export.py`): `emitters` (.ems + emitter attributes), `random_sets` (aud_wp_emitters), + `ambience_zones` (aud_wp_ambiences + crossfades), `regions` (district cSim streams -> region layers); readers in + `audio_formats.py` (`ems_emitters`, `name_id` lookup8, `region_layers`, `region_key`). +- **Force a location set** for listening: `SKATE_AUDIO_SET=` (for example `e_dwtn_office_buildings`). +- **Logs:** `World emitters: N of M records`; `AUDIO_EMITTER start|stop`; `AUDIO_RANDOM set|fire`; `AUDIO_AMBIENCE + zone|crossfade`. +- **Muted in-game test:** `SKATE_REPORT_CHILD=1 SKATE3_MODS=` `bin\skate3rust.exe --assets + ... --map ...\DownTown.skate --mute`, where the mod teleports through a list of stops (time, position). One mod + per folder, because every mod in a folder loads. +- **Long-run statistics:** `py -3.13 tools/long_run_stats.py ...`: per stop the retail set, fires per sound + vs weights, drawn intervals vs the set range, sounds never fired, and the retail ambience zone vs our lookup. +- **Compare with retail:** `py -3.13 tools/check_sets_vs_trace.py [first=District]`: each stop is + checked against its own district; stale post-teleport samples are skipped. +- **Bed level:** `py -3.13 tools/check_bed_level.py `: the capture's + level over a stop vs a prediction from the decoded bed (needs vgmstream from setup). + +## Lessons learned +- What sounded "missing" twice turned out to be an unported step, not physics: retail scales the bail region + impacts by a x1..5 COM-speed curve (`sub_824B0DA8`) and takes |dv.n|. Check the whole chain from physics to poster + before blaming the physics. +- Animation audio flags persist: push contact and footstep strength stay set for many ticks; brake foot-down + repeats every tick while braking. Use rising edges, explicit start/stop events and minimum gaps; never "flag set -> + play". +- `AudibleFootStepStrength` is a loudness level, not a step event: steps come from foot strikes. +- Loops: debounce only while continuously rolling; on re-contact start on the surface actually underneath (a + remembered surface caused "plays twice"). +- A water entry is also a wipeout: suppress the bail when a splash just played. +- A pick by measurement stays "not yet confirmed" until people hear it in game. diff --git a/tools/claude-skills/audio-tuning/tools/check_bed_level.py b/tools/claude-skills/audio-tuning/tools/check_bed_level.py new file mode 100644 index 00000000..6983687a --- /dev/null +++ b/tools/claude-skills/audio-tuning/tools/check_bed_level.py @@ -0,0 +1,91 @@ +"""Check a zone ambience bed's retail level against a prediction (the MixMap −11 dB base question). + +usage: py -3.13 check_bed_level.py + e.g. Aletown 06_dt_open 0.75 + +Measures: the recomp capture (`trace.f32`, stereo float32 48 kHz; `CAPTURE ` lines align it to +trace time) over the stop's listening window. 1 s RMS blocks; the low percentiles are mostly bed, since +one-shots and emitters only add on top. +Predicts: the bed's original 5-channel stream (decoded with vgmstream into .local/audio-re/amb_level) +× zone volume × base gain, folded the way the recomp host's capture folds it: +0.4·(front + surround + 0.5·centre) per side (aems-voice-graph-spec.md). Both channel orders (L C R Ls Rs / +L R C Ls Rs) are shown, because the stream's order is unverified. +""" +import struct +import subprocess +import sys +import wave +from pathlib import Path + +import numpy + +REPO = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO)) +from tools.owned_game.big import BigArchive # noqa: E402 + +WORK = REPO / '.local/audio-re/amb_level' +VGM = REPO / 'data/tools/vgmstream-cli/vgmstream-cli.exe' + + +def capture_window(session: Path, stop: str) -> numpy.ndarray: + marks, clock = [], [] + for line in (session / 'trace.tsv').open(encoding='utf-8', errors='replace'): + f = line.rstrip('\n').split('\t') + if f[0] == 'MARK': + marks.append((float(f[1]), f[2])) + elif f[0] == 'CAPTURE': + clock.append((float(f[1]), int(f[2]))) + start = next(t for t, label in marks if label.startswith(f'listen {stop}')) + end = next((t for t, label in marks if t > start and label.startswith(('at ', 'done'))), marks[-1][0]) + ms, frames = numpy.array([c[0] for c in clock]), numpy.array([c[1] for c in clock]) + a, b = (int(numpy.interp(t, ms, frames)) for t in (start + 5000, end - 5000)) + data = numpy.memmap(session / 'trace.f32', dtype=' numpy.ndarray: + WORK.mkdir(parents=True, exist_ok=True) + out = WORK / f'{bed}.wav' + if not out.exists(): + disc = REPO / '.local/skate3-disc/data/audio' + heads, bodies = BigArchive(disc / 'ambienceresident.big'), BigArchive(disc / 'ambience.big') + (WORK / f'{bed}.snr').write_bytes(heads.read(next(e for e in heads.entries if e.path.endswith(f'{bed}.snr')))) + (WORK / f'{bed}.sns').write_bytes(bodies.read(next(e for e in bodies.entries if e.path.endswith(f'{bed}.sns')))) + subprocess.run([str(VGM), '-o', str(out), str(WORK / f'{bed}.snr')], check=True, capture_output=True) + with wave.open(str(out)) as w: + ch, n = w.getnchannels(), w.getnframes() + pcm = numpy.frombuffer(w.readframes(n), dtype=' numpy.ndarray: + blocks = stereo[: len(stereo) // 48000 * 48000].reshape(-1, 48000, 2) + power = (blocks ** 2).mean(axis=(1, 2)) + return 10 * numpy.log10(numpy.maximum(power, 1e-12)) + + +def fold(pcm: numpy.ndarray, order: str) -> numpy.ndarray: + if order == 'LCR': + fl, c, fr, sl, sr = (pcm[:, i] for i in range(5)) + else: + fl, fr, c, sl, sr = (pcm[:, i] for i in range(5)) + return numpy.stack([0.4 * (fl + sl + 0.5 * c), 0.4 * (fr + sr + 0.5 * c)], axis=1) + + +def main() -> None: + session, stop, bed, volume = Path(sys.argv[1]), sys.argv[2], sys.argv[3], float(sys.argv[4]) + retail = block_db(capture_window(session, stop)) + print(f'retail capture at {stop}: {len(retail)} s; 1 s RMS p10 {numpy.percentile(retail, 10):.1f} dBFS, ' + f'p25 {numpy.percentile(retail, 25):.1f}, median {numpy.median(retail):.1f}') + pcm = decode_bed(bed) + print(f'bed {bed}: {pcm.shape[1]} channels, {len(pcm) / 48000:.0f} s') + for order in ('LCR', 'LRC'): + raw = block_db(fold(pcm, order)) + for label, base_db in (('no base', 0.0), ('-11 dB base', -11.0)): + gain_db = 20 * numpy.log10(volume) + base_db + print(f' predicted ({order}, {label}): p10 {numpy.percentile(raw, 10) + gain_db:.1f}, ' + f'p25 {numpy.percentile(raw, 25) + gain_db:.1f}, median {numpy.median(raw) + gain_db:.1f} dBFS') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/audio-tuning/tools/check_sets_vs_trace.py b/tools/claude-skills/audio-tuning/tools/check_sets_vs_trace.py new file mode 100644 index 00000000..36ada893 --- /dev/null +++ b/tools/claude-skills/audio-tuning/tools/check_sets_vs_trace.py @@ -0,0 +1,97 @@ +"""Compare retail's active location set (recomp trace WPPOS / WPSET / WPFIRE lines) with our region lookup. + +usage: py -3.13 check_sets_vs_trace.py [stop=District ...] + +Each stop is checked against ITS district only (the game loads one district; district maps overlap in +space). Stops of the Challenge Map > Locations lists are known below; others can be given as +`Name=District`; positions before the first stop use `first=District`. + +Reads the audio manifest of the current dev install (regions, random_sets) and, for every WPPOS line, +looks the position up in each district's `audio_emitters` region layer. Prints per teleport stop +(MARK "at ") the retail set, our set, the agreement rate, and the sounds retail fired. +""" +import json +import sys +from collections import Counter, defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO)) +from tools.asset_pipeline.audio_formats import region_key # noqa: E402 + + +STOP_DISTRICT = { + **{s: 'DownTown' for s in ('Aletown', 'All_Mart', 'Carverton_Memorial_Park', 'Crystal_Towers', 'Hotel_District', + 'Kube_Tower', 'Midtown_Promenade', 'Park_n_Play', 'Plaza_of_Solitude', 'Rippon_Towers', + 'Rosalita_Skate_Park', 'Slappys_Car_Lot', 'Tresoutta_Plaza', 'Uptown_Promenade')}, + **{s: 'Industrial' for s in ('Carverton_Quarry', 'Drydocks', 'Factory_Roofs', 'Ghetto_Spot', 'Haystings_Park', + 'Loading_Docks', 'Miracle_Bowl', 'Pipe_Works', 'Spillway_Entrance', 'The_Old_Factory', + 'The_Tanker', '2nd_and_Navy')}, + **{s: 'University' for s in ('Campus_Entrance', 'Chan_Center', 'Clock_Tower', 'Daly_Estates', 'Hartley_Stadium', + 'PCU_Library', 'Peterson_Pavilion', 'Super_Ultra_Mega_Park', 'The_Carvatron', + 'The_Observatory')}, +} + + +def main(trace: Path, extra: dict[str, str]) -> None: + marker = json.loads((REPO / 'data/installation.json').read_text()) + manifest = json.loads((REPO / 'data' / marker['directory'] / 'assets/private/audio/audio_manifest.json').read_text()) + sets = manifest.get('random_sets', {}) + name = lambda key: (sets.get(key) or {}).get('name') or key + tiles = {district: [{'box': t['box'], 'nodes': t['nodes'], 'keys': [int(k, 16) for k in t['keys']]} + for t in layers.get('audio_emitters', [])] + for district, layers in manifest.get('regions', {}).items()} + sound_bank = {} + for s in sets.values(): + for sound in s['sounds']: + sound_bank[sound['sound_id']] = sound['bank'] + + stop = 'before first stop' + stale = last_key = None + by_stop = defaultdict(lambda: {'agree': 0, 'total': 0, 'stale': 0, 'retail': Counter(), 'ours': Counter(), 'fired': [], 'pos': None}) + for line in trace.open(encoding='utf-8', errors='replace'): + f = line.rstrip('\n').split('\t') + if f[0] == 'MARK' and len(f) > 2 and f[2].startswith('at '): + stop = f[2][3:].split('#')[0].strip() + # The recomp ends its loading screen before the new district's streams load, so the + # previous area's set lingers for a few seconds (real hardware waits them out). + stale = last_key + elif f[0] == 'WPPOS' and len(f) >= 4: + x, _, z = (float(v) for v in f[2].split()) + retail = f[3] + if retail == '0000000000000000': + continue # loading / menu + if stale is not None and retail == stale: + by_stop[stop]['stale'] += 1 + continue + stale = None + last_key = retail + district = extra.get(stop) or STOP_DISTRICT.get(stop) or extra.get('first' if stop == 'before first stop' else '') + ours = None + for tile in tiles.get(district or '', []): + key = region_key(tile, x, z) + if key is not None: + ours = f'{key:016X}' + break + entry = by_stop[stop] + entry['total'] += 1 + entry['agree'] += ours == retail + entry['retail'][name(retail)] += 1 + entry['ours'][name(ours) if ours else 'none'] += 1 + entry['pos'] = (round(x, 1), round(z, 1)) + elif f[0] == 'WPFIRE' and len(f) >= 3: + by_stop[stop]['fired'].append(sound_bank.get(f[2], f[2])) + + total = agree = 0 + for stop, e in by_stop.items(): + if not e['total']: + continue + total += e['total'] + agree += e['agree'] + print(f"{stop:28s} at {e['pos']} retail {dict(e['retail'])} ours {dict(e['ours'])} agree {e['agree']}/{e['total']} (skipped {e['stale']} stale)") + print(f"{'':28s} fired {len(e['fired'])}: {', '.join(e['fired'])}") + print(f'overall agreement {agree}/{total}') + + +if __name__ == '__main__': + main(Path(sys.argv[1]), dict(a.split('=', 1) for a in sys.argv[2:])) diff --git a/tools/claude-skills/audio-tuning/tools/long_run_stats.py b/tools/claude-skills/audio-tuning/tools/long_run_stats.py new file mode 100644 index 00000000..f376812c --- /dev/null +++ b/tools/claude-skills/audio-tuning/tools/long_run_stats.py @@ -0,0 +1,81 @@ +"""Per-location statistics of a long recomp run (WPFIRE / WPINT / WPPOS / AMBST lines) against our data. + +usage: py -3.13 long_run_stats.py [...more traces] + +For each teleport stop (MARK "at "): the retail location set, fires and their sounds vs the set's +weights, drawn intervals vs the set's min..max, sounds never fired, and the ambience zone retail used vs +our `audio_ambience` region lookup at the same positions. Uses the dev install's audio manifest. +""" +import json +import sys +from collections import Counter, defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO)) +from tools.asset_pipeline.audio_formats import region_key # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_sets_vs_trace import STOP_DISTRICT # noqa: E402 + + +def load_manifest() -> dict: + marker = json.loads((REPO / 'data/installation.json').read_text()) + return json.loads((REPO / 'data' / marker['directory'] / 'assets/private/audio/audio_manifest.json').read_text()) + + +def main(traces: list[Path]) -> None: + m = load_manifest() + sets, zones = m['random_sets'], m.get('zones', {}) + tiles = {d: {layer: [{'box': t['box'], 'nodes': t['nodes'], 'keys': [int(k, 16) for k in t['keys']]} + for t in tl] for layer, tl in layers.items()} for d, layers in m['regions'].items()} + sound_bank = {s['sound_id']: s['bank'] for st in sets.values() for s in st['sounds']} + zname = lambda k: (zones.get(k) or {}).get('name') or k + total = Counter() + for trace in traces: + stop, stale = None, None + data = defaultdict(lambda: {'set': Counter(), 'fires': [], 'intervals': [], 'amb': Counter(), 'ours_amb': Counter()}) + for line in trace.open(encoding='utf-8', errors='replace'): + f = line.rstrip('\n').split('\t') + if f[0] == 'MARK' and len(f) > 2 and f[2].startswith('at '): + stop = f[2][3:].split('#')[0].strip() + elif stop is None: + continue + elif f[0] == 'WPPOS': + x, _, z = (float(v) for v in f[2].split()) + if f[3] != '0000000000000000': + data[stop]['set'][f[3]] += 1 + district = STOP_DISTRICT.get(stop, '') + key = next((region_key(t, x, z) for t in tiles.get(district, {}).get('audio_ambience', []) + if region_key(t, x, z) is not None), None) + data[stop]['ours_amb'][zname(f'{key:016X}') if key else 'none'] += 1 + elif f[0] == 'WPFIRE': + data[stop]['fires'].append(sound_bank.get(f[2], f[2])) + elif f[0] == 'WPINT': + data[stop]['intervals'].append(float(f[2])) + elif f[0] == 'AMBST' and f[2] != '0000000000000000': + data[stop]['amb'][zname(f[2])] += 1 + print(f'=== {trace.parent.name}') + for stop, d in data.items(): + if not d['set']: + continue + key = d['set'].most_common(1)[0][0] + s = sets.get(key, {}) + weights = {x['bank']: x['weight'] for x in s.get('sounds', [])} + fired = Counter(d['fires']) + lo, hi = s.get('min_interval'), s.get('max_interval') + ivs = [v for v in d['intervals'] if v > 0] + outside = [v for v in ivs if lo is not None and not (min(lo, hi) - 1e-3 <= v <= max(lo, hi) + 1e-3)] + never = sorted(b for b in weights if b not in fired) + print(f"{stop:24s} set {s.get('name')} ({lo}-{hi} s): {len(d['fires'])} fires, {len(ivs)} intervals" + f" {min(ivs, default=0):.2f}-{max(ivs, default=0):.2f} s, {len(outside)} outside range") + print(f"{'':24s} fired: {', '.join(f'{b}×{n}' for b, n in fired.most_common())}") + print(f"{'':24s} never fired ({len(never)}/{len(weights)}): {', '.join(never)}") + print(f"{'':24s} ambience retail {dict(d['amb'])} / ours at positions {dict(d['ours_amb'])}") + total['fires'] += len(d['fires']) + total['outside'] += len(outside) + print('total', dict(total)) + + +if __name__ == '__main__': + main([Path(a) for a in sys.argv[1:]]) diff --git a/tools/claude-skills/build-and-run/SKILL.md b/tools/claude-skills/build-and-run/SKILL.md new file mode 100644 index 00000000..232b79b6 --- /dev/null +++ b/tools/claude-skills/build-and-run/SKILL.md @@ -0,0 +1,80 @@ +--- +name: build-and-run +description: Build, test, and launch the skate3rust game on Windows. Use when asked to compile, run tests, play/launch the game, smoke-test a change in the real game, or check that a change builds. +--- + +# Build and run skate3rust + +Prerequisites: Rust (MSVC toolchain), LLVM (default `C:\Program Files\LLVM`), CMake for the static SDL3 build +(`scripts/Ensure-CMake.ps1` finds Visual Studio's bundled copy). Keep machine-specific install facts in your own +notes file in your checkout. + +## Build +- Release: `BUILD.bat` -> `scripts/Build.ps1` -> `cargo build -p skate-game --bin skate3rust --release`, then stages + exe + DLLs + `manifest.json` into `bin/`. +- Dev (debug profile, still opt-level 3): `BUILD_DEV.bat` (`Build.ps1 -Dev`). +- From an agent, prefer Bash so cargo's stderr isn't treated as an error: + ```bash + cd "" + export PATH="$HOME/.cargo/bin:$PATH" CMAKE="" + cargo build -p skate-game --bin skate3rust --release + ``` + Then stage with `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\Build.ps1` (don't hand-copy the + exe; `bin\manifest.json` holds hashes). + - Staging FAILS ("being used by another process") while a game runs from `bin\`: check `Get-Process skate3rust` + first and never kill someone's running game. The cargo build itself still succeeds. + - `Build.ps1` output redirected with `*>` is UTF-16; decode before grepping (`iconv -f UTF-16LE`) or check + `$LASTEXITCODE` and `(Get-Item bin\skate3rust.exe).LastWriteTime`. + - `target\release\skate3rust.exe` cannot run on its own (needs `bevy_dylib-*.dll` / `std-*.dll` beside it): run + from `bin\`. +- Release-package link configuration (static CRT + static Bevy, what CI ships). Verify native dependency changes + with it: + ```bash + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS='-C target-feature=+crt-static' \ + cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --bin skate3rust --no-default-features + ``` + Slow (full static Bevy rebuild, output in `target\x86_64-pc-windows-msvc\`); run it in the background. Required + whenever a native (C/C++) dependency or its build flags change: dev builds use the DLL CRT, so CRT mismatches only + show up here (`__imp_*` unresolved externals; see skill `vendor-patch`). +- `--locked` fails after editing `[patch.crates-io]` or dependencies: run `cargo update -p --offline` once. + +## Test +- Rust: `cargo test -p --release` (scope it; skate-core and skate-game are large). `skate-data`: use + `--lib --tests` (some of its examples may not compile upstream). Filter within skate-game: + `cargo test -p skate-game --release --bin skate3rust -- input::`. +- Python tools: `py -3.13 -m unittest tools.test_setup_assets tools.test_setup_refresh` (setup-related), or + `discover -s tools -p "test_*.py"`. +- Mods: `cargo run --locked -p skate-mods --example check_mod -- `. +- Asset validation without a window: `bin\skate3rust.exe --assets assets --test-world --check-assets` -> prints + `SKATE_ASSETS_READY`, exit 0. +- Many maps at once (on branches that have it): `bin\skate3rust.exe --assets assets --validate-maps`, then one + `.skate` path (or `TEST_WORLD`) per stdin line -> one `SKATE_MAP_CHECK {json}` stdout line each (errors, warnings, + collision_triangles, support_drop, grounded_ticks, visible_floor_gap, timings). Easiest via `check_maps.py` + (skill `regression-check`). + +## Run +- `PLAY.bat` (saved map, University default), `PLAY.bat path\to\map.skate`, or `PLAY.bat -trace [map] [file]`. Logs + go to `logs\game-*.log`. +- Needs `bin/skate3rust.exe`, converted assets behind the `assets\` junction (skill `asset-setup`), and a controller + for gameplay. +- **Screenshots without a player** (render/effect checks): `bin\skate3rust.exe --assets assets --map + \maps\.skate --teleport --verify .png`; + `SKATE_VERIFY_AT=` (default 4) sets when it captures, then it exits. With `SKATE3_MODS=` holding a + tiny mod that calls `sdk.camera.set(pos, look_at)` or `sdk.player.teleport(...)` in `on_update`, you can frame a + spot or trigger an event (for example a water drop) and capture several times. Keep such test mods in a gitignored + folder (for example `.local/test-mods/`), not in `mods/`. A connected controller can open the pause menu during a + capture: retry. +- Smoke test from an agent (opens a window briefly): start `bin\skate3rust.exe --assets "\assets"` with + `SKATE3_MODS=\mods`, redirect stdout/stderr into `.local\`, stop it after about 15-25 s, then grep the log for + `Controller`, `SDL`, `panic`, `ERROR`. +- Audio branches: add `--mute` to automated smoke runs (silences game + mod audio); master volume defaults to 25 % + (`settings\audio.json`). Launch long play sessions from Explorer/cmd rather than inside Windows Terminal (a + Terminal crash kills the attached game). Audio iteration: skill `audio-tuning`. +- Audio diagnostics switches (audio branches): `SKATE_AUDIO_TRACE`, `SKATE_AUDIO_TIMING`, `SKATE_AUDIO_STATE_LOG`, + `SKATE_AUDIO_SET`, `SKATE_AEMS_BANKS`, `SKATE_AUDIO_FMA`, plus world toggles `SKATE_AEMS_WORLD`, + `SKATE_AEMS_WORLD_PREFETCH`, `SKATE_AEMS_NPC_SKATERS`. +- Input backend: SDL3 by default; `SKATE3_INPUT=xinput` for the old XInput path. SDL hints can be set as env vars + (for example `SDL_JOYSTICK_HIDAPI_XBOX_ONE=1`). +- Run only one game instance at a time (engine or recomp); two GPU-heavy instances can make a machine unstable. + +After running, record any new environment findings in your notes file. diff --git a/tools/claude-skills/conflict-resolution/SKILL.md b/tools/claude-skills/conflict-resolution/SKILL.md new file mode 100644 index 00000000..bcf1e510 --- /dev/null +++ b/tools/claude-skills/conflict-resolution/SKILL.md @@ -0,0 +1,60 @@ +--- +name: conflict-resolution +description: Resolve git merge conflicts between branches and PR branches without losing anything - reuse resolutions already made (an integration branch, an earlier merge), stack conflicting PRs so they merge in order, and prove afterwards that no line from any branch was dropped and the tests still pass. Use whenever a merge, sync or PR combination conflicts, or before claiming branches "merge cleanly together". +--- + +# Conflict resolution + +Goal: branches that are meant to land together merge without conflicts, and no branch's change is lost. Don't +delete any branch until the merged result is verified. + +Helper scripts are in `tools/` next to this file (installed under `.claude/skills/conflict-resolution/tools/`). + +## 1. Look before merging +- Pairwise conflict map without touching a worktree: + `git merge-tree --write-tree --name-only A B` (conflicted files are listed after the tree id). +- Find an existing resolution: an integration branch that already merges the same branches. A file is safe to take + wholesale from it only if no unrelated commit touched it there: + `git rev-list --no-merges --not -- | wc -l` must be 0. +- Each worktree owns one branch (`git worktree list`); merge in that branch's worktree, never `git stash`. + +## 2. Resolve +In order of preference: +1. **Take the file from a reference that merges exactly the same commits** (`git checkout -- `). + Check: after committing, `git diff HEAD` is empty or only differs by what the reference has extra. + Trap: an integration branch can carry a LATER branch's lines; a later merge brings them in properly, but the + intermediate branch must not have them. +2. **Hunk-level reuse:** `python .claude/skills/conflict-resolution/tools/resolve_like.py ` accepts only + "ours+theirs" / "theirs+ours" with exact context found in the reference; the rest stays as a conflict for you. + Never loosen it: a fuzzy version once kept one side only and dropped a doc section, an SDK doc block and two + `validate()` arms. +3. **By hand:** keep both sides' intent. Lists/match arms/`mod` lines: keep both. A line both branches edited (for + example a macro field list or a system chain tuple): merge the fields of both into one line. +Before committing: `git grep -n -E '^(<<<<<<<|>>>>>>>) '` must be empty; watch for dropped braces. + +## 3. Prove nothing was lost (mandatory) +- `python .claude/skills/conflict-resolution/tools/check_lines_kept.py ` with + `` = the common parent branch of the sources (not `main` when they sit on another PR). Use the OLD heads of + branches you just moved (record them before merging). Read every hit: only same-line combinations and deliberate + rewrites are allowed. +- Run it from inside a worktree (outside a repo it sees no files; it exits 2 when it has no files). +- **Semantic conflicts** (merge is clean, build is not: one branch adds a parameter to a function, another adds a + call with the old arity). When a reference already merges the same branches, list its merge-only fixes: every + file whose reference history has only source-branch commits must be identical to ours: + `for f in $(git diff --name-only HEAD ); do [ $(git rev-list --no-merges --not -- $f | wc -l) = 0 ] && echo $f; done` + then take those files from the reference. +- Every old head is an ancestor: `git merge-base --is-ancestor `. +- Build and test the result (skill `build-and-run`; know the pre-existing upstream failures), and when audio + changed, the e2e bench must stay IDENTICAL (skill `optimisation`). + +## 4. PRs that conflict with each other +- Two open PRs touching the same lines cannot both merge cleanly in any order. Either stack them (the later PR + merges the earlier one, its description says "merges after #N") or combine them into one PR. Maintainers usually + prefer fewer, themed PRs; ask when unclear. +- After moving a PR branch: update its description so it matches the branch. +- Close PRs or delete branches only after section 3 passes on the pushed result; `gh pr close` without + `--delete-branch`; delete branches only with the owner's agreement. + +## 5. Commits +Merge commits are commits: sign them like any other commit if the project signs, and only make them when asked. +Amend a merge only while it is unpushed; after a push, fix forward. diff --git a/tools/claude-skills/conflict-resolution/tools/check_lines_kept.py b/tools/claude-skills/conflict-resolution/tools/check_lines_kept.py new file mode 100644 index 00000000..9cb2f3e5 --- /dev/null +++ b/tools/claude-skills/conflict-resolution/tools/check_lines_kept.py @@ -0,0 +1,51 @@ +"""After merging branches: list lines a source branch added that the merged result no longer has. + +Usage (in any worktree of the repo): + python check_lines_kept.py ... [-- ...] + + must be what the branches have in common (the branch they all forked from), NOT main when +they sit on another PR: with main as base, the parent PR's own lines that a child rewrote on purpose +flood the report. Without files, every file that differs between and is checked. +Lines are compared stripped, whitespace-only lines ignored. Expected hits: a line both branches edited +(the merged line combines them) and lines a later branch rewrote; read each one, anything else is a +dropped change. +""" +import collections +import subprocess +import sys + + +def git(*args): + return subprocess.run(['git', *args], capture_output=True).stdout.decode('utf-8', 'replace').replace('\r\n', '\n') + + +def main(): + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + args = sys.argv[1:] + if '--' in args: + i = args.index('--') + revs, files = args[:i], args[i + 1:] + else: + revs, files = args, [] + base, result, branches = revs[0], revs[1], revs[2:] + if not files: + files = [f for f in git('diff', '--name-only', base, result).split('\n') if f] + if not files: + print('no files to check (wrong directory or revisions?)') + sys.exit(2) + missing = 0 + for f in files: + have = collections.Counter(l.strip() for l in git('show', f'{result}:{f}').split('\n') if l.strip()) + for b in branches: + diff = git('diff', '-U0', f'{base}...{b}', '--', f) + added = {l[1:].strip() for l in diff.split('\n') if l.startswith('+') and not l.startswith('+++') and l[1:].strip()} + for line in sorted(added): + if not have[line]: + missing += 1 + print(f'{f} [{b}] missing: {line[:160]}') + print(f'checked {len(files)} file(s); missing lines: {missing}') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/conflict-resolution/tools/resolve_like.py b/tools/claude-skills/conflict-resolution/tools/resolve_like.py new file mode 100644 index 00000000..725b1214 --- /dev/null +++ b/tools/claude-skills/conflict-resolution/tools/resolve_like.py @@ -0,0 +1,59 @@ +"""Resolve merge-conflict hunks the way a reference branch already resolved them. + +Usage (in the worktree with the conflicted merge): + python resolve_like.py ... + +For each hunk it builds "ours + theirs" and "theirs + ours" with the two context lines on each side +and accepts one only if that exact text appears in the reference file. Anything else (one side only, +a hand-rewritten line) stays as a conflict and is reported: resolve those by hand or take the whole +file from a reference that merges exactly the same commits. A looser match once dropped whole hunks +(a doc section, two validate arms), so do not loosen it; always run check_lines_kept.py afterwards. +""" +import re +import subprocess +import sys + +HUNK = re.compile(r'<<<<<<< [^\n]*\n(.*?)(?:\|\|\|\|\|\|\| [^\n]*\n.*?)?=======\n(.*?)>>>>>>> [^\n]*\n', re.S) + + +def context(text, start, end): + before = text[:start].split('\n') + pre = '\n'.join(before[-3:]) # the two full lines before the hunk + '' (the hunk starts a line) + post = '\n'.join(text[end:].split('\n')[:2]) + return pre, post + + +def main(): + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + ref = sys.argv[1] + total_left = 0 + for path in sys.argv[2:]: + raw = open(path, encoding='utf-8', newline='').read() + nl = '\r\n' if '\r\n' in raw else '\n' + text = raw.replace('\r\n', '\n') + shown = subprocess.run(['git', 'show', f'{ref}:{path}'], capture_output=True) + refs = shown.stdout.decode('utf-8', 'replace').replace('\r\n', '\n') if shown.returncode == 0 else '' + out, pos, left = [], 0, 0 + for m in HUNK.finditer(text): + ours, theirs = m.group(1), m.group(2) + pre, post = context(text, m.start(), m.end()) + choice = next((c for c in (ours + theirs, theirs + ours) if refs and pre + c + post in refs), None) + out.append(text[pos:m.start()]) + if choice is None: + out.append(m.group(0)) + left += 1 + line = text[:m.start()].count('\n') + 1 + print(f' {path}:{line}: needs a hand resolution') + else: + out.append(choice) + pos = m.end() + out.append(text[pos:]) + open(path, 'w', encoding='utf-8', newline='').write(''.join(out).replace('\n', nl)) + print(f'{path}: {left} hunk(s) left') + total_left += left + sys.exit(1 if total_left else 0) + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/living-world/SKILL.md b/tools/claude-skills/living-world/SKILL.md new file mode 100644 index 00000000..5beafd03 --- /dev/null +++ b/tools/claude-skills/living-world/SKILL.md @@ -0,0 +1,52 @@ +--- +name: living-world +description: Build the living world (ambient NPC skaters, pedestrians, traffic, movable objects, zombie mode, standing pros/marquee actors) in the Rust engine, ported from the retail code with scripted tests and a mod API. Use when continuing NPC skater, ped or traffic work, answering a question about how retail populates the world, or planning its milestones. +--- + +# Living world: NPC skaters, pedestrians and traffic + +Upstream PR #52 ("Living world: NPC skaters, pedestrians and traffic") is the home of this work; read its +description and docs first. Movable objects (DMOs) build on upstream PR #15 (dynamic props; credit its author). + +## Source of truth +Retail code (recomp disassembly as reference, skill `recomp-research`) and stock data. Recomp traces (PEDCOLL, NPC*, +SKATEB, VEH*) only validate; the recomp does not reproduce peds and NPC skaters perfectly. Label every value +[code]/[data]/[trace] with its address. No game code or data in the repo; values come from each user's own assets at +setup time (data-driven defaults a mod can override). + +Format references worth checking (credit them if used): community Skate 3 modding tool documentation for AIPATH +structs, NavPower navmesh constants and trigger types (including Crowd). + +## Scope (as decided for the PR) +- **Free Play mode:** the offline pause-menu mode with Traffic, Pedestrians and "A.I. Skaters" On/Off + (`freeskate_options`, mode 3, `0x830B7AE8`+332/+336/+340). Career free roam has no switch. No NPC skaters in parks. +- **Online games:** no ambient peds, traffic or NPC skaters spawn (census spawn pass and AI desired count gated by + the online bytes `0x830B7C2B` / `0x83082929`, `sub_826B71F0`, `sub_8245BA28`); culling still runs. +- Time of day is fixed. Zombie mode (cheat "zombie", free skate only) is in scope. Pros/marquee actors standing around + in free skate are in scope. Angry peds throw held items; peds also randomly throw held items into trash cans. +- **Vehicles (traffic)** and **movable objects** are part of the same work. +- **Multiplayer-ready, no networking yet:** decisions run in one place (a future host) and downstream systems + consume serialisable spawn / despawn / event records; observers are a list (local player now, remote players + later); stable `LivingWorldId`s; deterministic ticks and seeded per-entity RNGs; a `NetRole` (Standalone default, + Host, Client stub). No transport or replication code until it is asked for. +- Confirm a mechanic exists in Skate 3 before researching it (security guards, for example, are Skate 2 leftovers + and not part of the Skate 3 port). + +## Shared design (do once) +One `livingworld` setup export group, one population core (budget, spawn ring, cull, slots shared with online +players), one CrowdCharacter renderer (reuses multiplayer skinned looks), kinematic collision proxies (like +`physics::network::Proxies`), one `sdk.living_world` mod API (spawn tables, models, behaviour overrides, density, +cleanup on disable), host-authoritative multiplayer. + +## Tools +- `tools/render_glb.py` (next to this file): renders a converted `.glb` model to a PNG offscreen (numpy + Pillow), + for checking ped / NPC models without launching the game. +- Recomp analysis: `recomp-research/tools/first_pass.py`, `trace.py`; ped chase timelines and vehicle summaries are + written per topic from the NPC / traffic trace lines (skill `recomp-scripted-runs`, trace line catalogue). + +## Testing +Scripted and headless wherever possible: data-gated `skate-data` tests on your own assets, deterministic population +tests with a seeded RNG, behaviour-graph unit tests, collision thresholds (knockdown > 3.0, 6.0 flagged), per-frame +cadence normalised to the 360's ~30 fps. Gameplay feel and NPC encounters come from passive sessions where a person +plays (scripts can't steer to NPCs). Run skill `regression-check` before calling anything done, and document each +change with its retail evidence and tests. diff --git a/tools/claude-skills/living-world/tools/render_glb.py b/tools/claude-skills/living-world/tools/render_glb.py new file mode 100644 index 00000000..4a20cbc6 --- /dev/null +++ b/tools/claude-skills/living-world/tools/render_glb.py @@ -0,0 +1,196 @@ +"""Software render of a living-world GLB (ped or vehicle) to PNG, for visual checks without the engine. + +usage: py -3.13 render_glb.py [--view side|front|rear|top|three_quarter] [--skip-role windows] + [--clip-x 0.0] [--size 640] [--untextured] [--pose dump.json --pose-index 0] [--tint 'r,g,b;r,g,b;gain'] + +Draws LOD0 in the bind pose (glTF positions are model space), textured with the base-colour map +(nearest sample) and simple lambert shading; ``--skip-role windows`` leaves out primitives whose +``extras.role`` matches (to look into a car), ``--clip-x`` drops triangles with all x above the value +(a cut through the cabin). Reference tool only; nothing here ships. +""" +import argparse +import io +import json +import struct + +import numpy as np +from PIL import Image + +COMPONENTS = {5126: np.float32, 5123: np.uint16, 5125: np.uint32, 5121: np.uint8} +WIDTH = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16} + + +def load(path): + data = open(path, 'rb').read() + length = struct.unpack_from('= 0 and parents[i] in world and i in bind and parents[i] in bind: + world[i] = world[parents[i]] @ np.linalg.inv(bind[parents[i]]) @ bind[i] + out = [] + for k, n in enumerate(joints): + i = names.index(n) + out.append(world[i] @ ibm[k] if i in world else np.eye(4)) + return np.array(out) + + +def tint(tex, chassis, secondary, gain=1.0): + """The engine's V3 chassis tint (``skate-game`` ``living_world::vehicles::tint_rgba8``): blue mask + -> chassis x b, red mask -> secondary x r, identity for the base palette (0,0,1) / (1,0,0).""" + px = tex[..., :3].astype(float) / 255.0 + r, g, b = px[..., 0], px[..., 1], px[..., 2] + mb = np.where(b > 0, np.clip((b - np.maximum(r, g)) / np.maximum(b, 1e-9), 0, 1), 0) + mr = np.where(r > 0, np.clip((r - np.maximum(g, b)) / np.maximum(r, 1e-9), 0, 1), 0) + pb, pr = np.minimum(b * gain, 1), np.minimum(r * gain, 1) + out = px.copy() + for i in range(3): + base_b = b if i == 2 else 0 + base_r = r if i == 0 else 0 + out[..., i] = px[..., i] + mb * (pb * chassis[i] - base_b) + mr * (pr * secondary[i] - base_r) + res = tex.copy() + res[..., :3] = np.clip(np.round(out * 255), 0, 255).astype(np.uint8) + return res + + +def render(path, out, view='side', skip=(), clip_x=None, size=640, textured=True, pose=None, pose_index=0, tints=None): + doc, accessor, image = load(path) + mesh = doc['meshes'][0] + skin_m = skin_matrices(doc, accessor, pose, pose_index) if pose else None + tris = [] + for prim in mesh['primitives']: + if prim.get('extras', {}).get('role') in skip: + continue + pos = accessor(prim['attributes']['POSITION']).astype(np.float64) + if skin_m is not None: + j = accessor(prim['attributes']['JOINTS_0']).astype(int) + w = accessor(prim['attributes']['WEIGHTS_0']).astype(np.float64) + if w.max() > 1.5: + w = w / 255.0 + h = np.concatenate([pos, np.ones((len(pos), 1))], 1) + pos = sum(w[:, k:k + 1] * np.einsum('nij,nj->ni', skin_m[j[:, k]], h) for k in range(4))[:, :3] + uv = accessor(prim['attributes']['TEXCOORD_0']).astype(np.float64) + idx = accessor(prim['indices']).reshape(-1, 3) + material = doc['materials'][prim['material']] + tex = None + if textured and 'baseColorTexture' in material.get('pbrMetallicRoughness', {}): + tex = image(doc['textures'][material['pbrMetallicRoughness']['baseColorTexture']['index']]['source']) + if tints and material.get('extras', {}).get('kind') == 'vehicle_chassis': + tex = tint(tex, *tints) + tris.append((pos, uv, idx, tex)) + rot = {'side': np.array([[0, 0, 1], [0, 1, 0], [-1, 0, 0]]), # looking at the car's +x side, +z to the right + 'front': np.eye(3), 'rear': np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]]), + 'top': np.array([[0, 0, 1], [1, 0, 0], [0, 1, 0]]), + 'three_quarter': None}[view] + if rot is None: + a, b = np.radians(35), np.radians(20) + ry = np.array([[np.cos(a), 0, np.sin(a)], [0, 1, 0], [-np.sin(a), 0, np.cos(a)]]) + rx = np.array([[1, 0, 0], [0, np.cos(b), -np.sin(b)], [0, np.sin(b), np.cos(b)]]) + rot = rx @ ry + allpos = np.concatenate([p for p, _, _, _ in tris]) @ rot.T + lo, hi = allpos.min(0), allpos.max(0) + scale = 0.9 * size / max(hi[0] - lo[0], hi[1] - lo[1]) + centre = (lo + hi) / 2 + colour = np.full((size, size, 3), 235, np.uint8) + depth = np.full((size, size), -np.inf) + light = np.array([0.4, 0.8, 0.45]) + light /= np.linalg.norm(light) + for pos, uv, idx, tex in tris: + p = pos @ rot.T + sx = (p[:, 0] - centre[0]) * scale + size / 2 + sy = size / 2 - (p[:, 1] - centre[1]) * scale + for a, b, c in idx: + if clip_x is not None and min(pos[a, 0], pos[b, 0], pos[c, 0]) > clip_x: + continue + xs, ys = np.array([sx[a], sx[b], sx[c]]), np.array([sy[a], sy[b], sy[c]]) + x0, x1 = max(int(xs.min()), 0), min(int(xs.max()) + 1, size) + y0, y1 = max(int(ys.min()), 0), min(int(ys.max()) + 1, size) + if x0 >= x1 or y0 >= y1: + continue + gx, gy = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5) + d = (ys[1] - ys[2]) * (xs[0] - xs[2]) + (xs[2] - xs[1]) * (ys[0] - ys[2]) + if abs(d) < 1e-12: + continue + w0 = ((ys[1] - ys[2]) * (gx - xs[2]) + (xs[2] - xs[1]) * (gy - ys[2])) / d + w1 = ((ys[2] - ys[0]) * (gx - xs[2]) + (xs[0] - xs[2]) * (gy - ys[2])) / d + w2 = 1 - w0 - w1 + inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0) + if not inside.any(): + continue + z = w0 * p[a, 2] + w1 * p[b, 2] + w2 * p[c, 2] + region = depth[y0:y1, x0:x1] + hit = inside & (z > region) + if not hit.any(): + continue + normal = np.cross(p[b] - p[a], p[c] - p[a]) + shade = 0.35 + 0.65 * abs(normal @ (rot @ light)) / (np.linalg.norm(normal) + 1e-20) + if tex is not None: + u = w0 * uv[a, 0] + w1 * uv[b, 0] + w2 * uv[c, 0] + v = w0 * uv[a, 1] + w1 * uv[b, 1] + w2 * uv[c, 1] + h, w = tex.shape[:2] + ti = np.clip((v % 1.0) * h, 0, h - 1).astype(int) + tj = np.clip((u % 1.0) * w, 0, w - 1).astype(int) + rgb = tex[ti, tj, :3].astype(float) + else: + rgb = np.full(gx.shape + (3,), 190.0) + block = colour[y0:y1, x0:x1] + block[hit] = np.clip(rgb[hit] * shade, 0, 255).astype(np.uint8) + region[hit] = z[hit] + Image.fromarray(colour).save(out) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('glb') + parser.add_argument('out') + parser.add_argument('--view', default='side') + parser.add_argument('--skip-role', action='append', default=[]) + parser.add_argument('--clip-x', type=float) + parser.add_argument('--size', type=int, default=640) + parser.add_argument('--untextured', action='store_true') + parser.add_argument('--pose', help='ped pose dump (peds_tests living_world_ped_pose_dump)') + parser.add_argument('--pose-index', type=int, default=0) + parser.add_argument('--tint', help='chassis r,g,b[;secondary r,g,b[;gain]] (engine V3 tint rule)') + a = parser.parse_args() + tints = None + if a.tint: + parts = a.tint.split(';') + tints = ([float(x) for x in parts[0].split(',')], [float(x) for x in parts[1].split(',')] if len(parts) > 1 else [1.0, 0.0, 0.0], + float(parts[2]) if len(parts) > 2 else 1.0) + render(a.glb, a.out, a.view, tuple(a.skip_role), a.clip_x, a.size, not a.untextured, a.pose, a.pose_index, tints) diff --git a/tools/claude-skills/make-mod/SKILL.md b/tools/claude-skills/make-mod/SKILL.md new file mode 100644 index 00000000..c9204a48 --- /dev/null +++ b/tools/claude-skills/make-mod/SKILL.md @@ -0,0 +1,63 @@ +--- +name: make-mod +description: Create or modify a Lua mod (API 2) for skate3rust - physics bodies, graphics, vehicles, minigames, audio. Use when asked to make, fix, or validate a mod. +--- + +# Make a skate3rust mod + +1. Read `sdk/AGENTS.md` first: it is the authoritative workflow. Then `sdk/skate.lua` (API annotations) and the + relevant `sdk/*_API.md`. +2. Start from the closest example in `sdk/examples/` (physics-sandbox, game-of-skate, simon-says, skyline, + broken-bones, wipeout-challenge, ...) or `mods/Skyline_Drive_Mod/`. +3. `mod.json` must have `"api": 2`, an `id`, `name`, `version`, `entry` (usually `main.lua`), optional `settings`, + optional `"enabled_by_default": false` (dev / test mods: off until enabled in the mod menu or by + `SKATE3_MODS_ENABLE=`; a saved preference wins). The entry file returns a callback table. +4. No host vehicle API exists: build vehicles from primitives (bodies, joints/motors, sensors) in Lua. +5. Validate: `cargo run --locked -p skate-mods --example check_mod -- `. +6. Install by placing the folder or `.zip` under `mods/`. + +Tips: `on_update(event)` / `on_ui_update(event)` / `on_fixed_update(event)` get `event.dt` (seconds); count it down +for timed HUD text. `sdk.ui.text(id, "")` clears a text line; `sdk.ui.remove(id)` removes the element. + +Engine side, if the API itself must change: `crates/skate-game/src/modding/`, runtime `crates/skate-mods/`, +dynamics `crates/skate-dynamics/`. Every engine feature should be reachable from a mod: data-driven values (retail +values as defaults from setup data), stable identities a mod can override, a mod-facing entry point next to the +engine-facing one, and cleanup when the mod is disabled. +Note: `.glb` files are gitignored except explicitly whitelisted ones; add a `!` exception to `.gitignore` if a mod +asset should be committed (never retail-derived assets). + +## Audio and world-audio APIs (audio branches; upstream PRs #32, #36, #43, #44) +Check `sdk/skate.lua` and the audio-modding guide on the branch you build against for the exact capability levels; +the summary below is the shape of the API. + +- **World audio** (extension `world_audio`): `sdk.world_audio.spawn(key, 'traffic'|'ped'|'skater', opts)` / + `update` / `event(key, 'horn'|'alarm'|'speech', opts)` / `remove` / `read(key)` / `info()` publish objects to the + game's retail world audio (the same components engine systems use). Per-mod and total object caps apply; objects + are parked after 0.5 s without an update and removed on disable. Retail decides who is audible + (`read(key).audible`). Spawns that can fail (for example from a missing file) go through `sdk.commands.request` so + a missing file doesn't fail the mod. +- **Content (no Lua):** `audio.json` at the mod root (capability `audio_content`): `replace` / `add` retail audio by + identity (sample slots, banks, Splice trees, grain members, wheel streams, ambience beds, emitter records, location + sets, zones, crossfades, speech takes, `location_programs`, `crossfade_layouts`), `tuning` field merges, + `maps.` (custom-map audio). A crossfade bank's layout comes from its `c_main_ambience_crossfade` program or + `add.crossfade_layouts` (a WAV-only mod bank; wins over a program); with neither it is silent. Speech clip names + must parse (`_[_]_`); unknown clips are merge warnings. First mod by id wins per + identity; conflicts show in the mod menu. WAVs PCM16 1-2 ch 8-48 kHz, length limits apply (beds longer). +- **Custom maps:** `.audio.json` next to the `.skate`, or a `.skate` `AUDO` extension (schema 1 JSON): + district, ems, emitters / reverb zones, box regions per layer, crossfade bank. +- **Runtime (capability `audio`):** `sdk.audio.post(key, class, words)` / `redeliver` / `release`, + `set_global(name, value|nil)`, `watch{globals, mixmap}`, `handle` / `global` / `mixmap` / `info`, + `sdk.engine.inspect(key, 'audio_catalog')`. Posts apply at the next audio pass; handles die at map change / + restart. Later levels add `sdk.audio.play{native=true, falloff={radius,core,curve}, reverb, group}` (a WAV through + the native mixer), `sdk.audio.set_tuning(domain, patch|nil)` / `tuning(...)` / `tuned()` (domains player / world / + bus / reverb; strict field merge), `sdk.audio.set_mixmap_input`, `sdk.audio.seed`, and world `emitter` / + `reverb_zone` spawns. +- **Events (capability `audio_events`):** `sdk.audio.subscribe{tags={...}}`, `sdk.audio.events()` (pop, land, + grind_start, grind_end, footstep, horn, alarm, emitter, zone_change, speech). Observe only. Rules: + `sdk.audio.rule(key, {match, action = mute|replace|layer, play})` and `audio.json` `rules`. +- Content changes hot-swap where possible (`sdk.audio.info().swaps / last_change`); `audio.json` edits reload while + the script runs. +- **Check:** `cargo run -p skate-mods --example check_mod -- --install [--with ]`. +- Examples: `sdk/examples/audio-example` (ships), `mods/audio-content-test` (dev, off by default; + `SKATE3_MODS_ENABLE=dev-audio-content-test`), `mods/world-audio-test/` (dev). Synthesize test sounds (for example + with a small Python script); never copy game audio into a mod. diff --git a/tools/claude-skills/optimisation/SKILL.md b/tools/claude-skills/optimisation/SKILL.md new file mode 100644 index 00000000..3697deba --- /dev/null +++ b/tools/claude-skills/optimisation/SKILL.md @@ -0,0 +1,142 @@ +--- +name: optimisation +description: Make engine code faster WITHOUT changing behaviour - measure first, prove output is identical, and put every change through adversarial review before it lands. Use for any performance work (stutter, frame time, audio/physics/render cost, load times), and for further passes over the native audio. +--- + +# Optimisation + +Built from the native-audio optimisation passes on the audio branch (upstream PR #32): the tools, baselines, +proofs, pitfalls and the review checklist that worked there. Helper scripts are in `tools/` next to this file +(installed under `/.claude/skills/optimisation/tools/`; the shell scripts find the repo root from there). + +## Rules (binding) +- **Optimise only with proof of identical behaviour** (skill `regression-check` section 3b). Byte-identical output + is the bar. A tolerance needs an explicit reason and the project owner's OK. Never reassociate floats, never swap + `mul_add` for `a * b + c` (or the reverse), never change the order of RNG draws. +- **Measure first, then change.** No change without a baseline number for the thing it should improve, measured the + same way after it, with repeats (noise). +- **Worst case matters more than the average:** p99 / p99.9 and the per-second maximum, not only p50. A change that + improves p50 and worsens p99 is a regression. +- **Gameplay data comes from real play sessions** (state logs recorded while a person plays), not scripted skating + runs. Benchmarks are headless. Data with malformed rows is discarded, not parsed around. +- Do the adversarial review yourself and write it down: concrete counter-examples tried, not "looks fine". +- Product-level trade-offs belong to the project owner: raising the CPU baseline (`+fma`), adding `unsafe` to a + crate that forbids it, trading memory for frame time, adding threads. Measure them, then report them. + +## Process +1. **Reproduce and measure in real play.** Read the `AUDIO_TIMING` lines (`SKATE_AUDIO_TIMING=1`, one line per second + in `logs\game-*.stderr.log`: `name=max/avg us x count`). Summarise per system (per-second max median / p90 / + worst, average, calls per second) with `tools/audio_timing_summary.py LOG...`. Correlate spikes with the log lines + around them (for example `AUDIO_EMITTER start ... (native)`). +2. **Reproduce headless and repeatably.** `tools/e2e_bench.sh LABEL [row|fps300]`: the e2e scenarios plus whole + play sessions replayed through the e2e harness, timed per game-thread call and per render block. Every output is + hashed. Inputs: + - `.local/audio-re/opt/scen/*.tsv` (the synthetic e2e scenarios); + - `.local/audio-re/opt/real/*.tsv`, made with `py -3.13 .claude/skills/aems-port/tools/e2e/scenarios.py + --from-log --cut r0-999999 real_X .local/audio-re/opt/real`. Check that the row count equals the + log's lines - 1: `from_log` silently drops malformed rows. +3. **Find the cause** before touching code. Use temporary per-stage timers: a throw-away `prof` module with + thread-local `Instant` accumulators around each stage, printed per scenario by the e2e test. Also count events + (voices per block, kernel samples per path, zero-input blocks, settled filters). Write the numbers down. Remove + all of it before landing (keep a pristine copy of the sources). Micro-benchmarks for single questions: small + `.rs` files built with plain `rustc -O`. +4. **Change one thing at a time.** Keep the old implementation as a test-only reference and compare bit for bit on + synthetic edge cases (see the checklist), and in the e2e hashes. +5. **Prove identity:** `tools/compare_runs.sh base NEW` must print IDENTICAL for every set and mode. Also run + `cargo test -p skate-audio --release --locked`, `cargo test -p skate-game --release --bin skate3rust --locked -- + game_audio::`, and the `--no-run` build. +6. **Adversarial review** (checklist below). Revert anything not proven. +7. **Re-measure** with repeated runs (at least 2 before and 2 after), and compare p50 / p90 / p99 / p99.9. Maxima on + a shared machine are OS noise: they move between runs and block indices. Don't claim them. +8. **Document** in the change's docs entry and the PR: numbers, proofs, rejected ideas. + +## Tools (in `tools/`) +- `e2e_bench.sh LABEL [MODE...]`: builds into `.local/opt-target`, its own target dir, so it never waits on or + invalidates the main build. Renders scen + real in `row` (one host call per 60 Hz row) and `fpsN` (the game's + host at N fps, console MixMap cadence) modes. Writes `.local/audio-re/opt/runs/LABEL/-/{timing.txt, + hashes.txt}`. `LABEL=base` keeps the `.f32` renders (large); other labels delete them unless `KEEP=1`. Honours + `CARGO_TARGET_DIR` / `RUSTFLAGS` for experiments. +- `compare_runs.sh BASE NEW`: hash identity per set and mode, then `timing_summary.py`. +- `timing_summary.py RUN...`: pools `*.ours.timing.tsv` (written by the e2e harness when `E2E_TIMING=1`) and prints + n / p50 / p90 / p99 / p99.9 / max / sum per run. +- `audio_timing_summary.py LOG...`: the real-play `AUDIO_TIMING` readout, incl. the render load gauges + (`block_voices` / `block_instances` / `block_grains`). +- `frame_log_summary.py LOG [--hitches N]`: in-game frame time. Enable with the graphics menu "Frame-time counter" + or `SKATE_FRAME_LOG=` (per-frame TSV: frame_ms, fixed_steps, fixed_ms, main_ms, hitch). Prints + percentiles, per-second worst, physics catch-up frames, and the longest hitches with UTC time and main-thread vs + render/GPU attribution. Use this first for any stutter report. +- Allocation check: `crates/skate-audio/tests/render_alloc.rs`, a counting global allocator (thread-local switch) + around steady-state `Runtime::render_block` on a full scene. It asserts 0 allocations. To find an allocation site, + print `Backtrace::force_capture()` from the allocator with counting switched off around the print. +- Baselines of other modes later: swap the pristine sources in, run `e2e_bench.sh base_x MODE...`, restore the new + sources and confirm with `diff -rq`. Keep the tree compiling throughout: both versions build. + +## Review checklist (do every item; write down what you tried) +- **Purity:** does the shortcut rely on a function being pure? Check that it reads no hidden state: caches, RNG, + counters, `self` fields outside the arguments. Key memos on the exact bits (`to_bits`), never `==` (-0.0 == 0.0, + NaN != NaN). +- **Signed zero and NaN:** `+0.0` vs `-0.0` inputs; NaN / inf coefficients and histories; a NaN parameter. `0*x` is + +-0 and `+-0 + c` is c only for finite x. Check at run time where possible. +- **Position dependence:** retail kernels associate by position (the biquad's `n % 8` groups). Any block-level + shortcut must keep group alignment: repeat whole groups, and continue at a multiple of 8. +- **Block sizes:** 0, 1, 7, 8, 9, 15, 16, 24, 255, 256, 264. +- **State across blocks:** filters settling then being disturbed, coefficient changes (jitter every console frame, + re-rolls), bypass on/off (history clear), map reloads / bank unloads, voice stealing / `max_voices`, releases + mid-block. +- **Frame-rate extremes:** e2e `row` and `E2E_FPS=300`. Add `E2E_FPS=30` / `1000` when host-side code changes. +- **Integer identities:** a modulo replacement must equal `%` for every input, overflowed ones included. +- **Allocation order and RNG order:** turning a `Vec` into an array must keep the evaluation order of any + side-effecting closure. Use an explicit loop for RNG draws. +- **Is the speed-up real?** Repeat runs and report the spread. Check whether the cost moved to another thread or to + the game thread, and whether p99 got worse. Profile instrumentation itself costs time: compare like with like. + +## What the audio passes found (worked examples) +- **The cost was `f32::mul_add` as a library call.** Without `+fma` every fused multiply-add is a call into the CRT's + `fmaf`: roughly 3x slower per biquad channel than hardware FMA. Results are bit-identical. Enabling hardware FMA + needs `-C target-feature=+fma` (raises the CPU baseline) or runtime dispatch. Done as runtime dispatch: a crate + (`skate-audio-fma`) writes each per-sample `mul_add` loop once (`#[inline(always)]`) and wraps it as `plain::*` and + `#[target_feature(enable = "fma")] fma::*`, selected after `is_x86_feature_detected!`, cached in an `AtomicU8`, + `SKATE_AUDIO_FMA=0` forces plain, one `unsafe` call with a `// SAFETY:` comment. Render p50 roughly halved; all + e2e outputs byte-identical on both paths. +- **NaN payload is the one FMA difference:** with several NaN operands, which NaN propagates depends on operand + order. Compare with NaNs mapped to one marker and count payload differences. LLVM constant-folds edge-value calls + with its own NaN: feed test inputs through `std::hint::black_box`. +- Exact shortcuts that worked: a settled filter on an 8-periodic block (run one group of 8; if the history comes + back bit-identical, repeat it); silent tails (only the feedback FMAs); twin silent channels with equal histories + (copy); trig reuse when a lane repeats; `%` -> a proven `wrap` in ring buffers; stack arrays instead of per-voice + `Vec`s; reused pick buffers; an allocation-free render (pools, stack buffers, in-place loops). +- Dropped because the gain was not real: dispatch hoisting in the evaluator walk (exact, but no faster); a + silent-bus memo (exact, but its key compares and silence scans cost what the skipped EQs saved). +- Lessons: + - The e2e real-session replays are the best identity net (they hit jitter, re-roll, settling and silent-bus paths + that synthetic scenarios don't). + - Count paths (how many blocks take each shortcut) before estimating gains, then still measure. + - Exact code motion in an interpreter loop buys nothing; only a different program representation would. + - Make an allocation test fail on the old code first. + - Pool shape matters: zero-capacity lists re-allocate on every post; use best fit and give new lists a little room. + - A data-gated test run needs `--no-fail-fast`, otherwise later test binaries never run and there is nothing to + diff. + - Proof per change on a cumulative tree: if each state is identical to the reference, so is every step. + - Time with interleaved saved binaries when other work shares the machine (A B A B rounds); name disturbed rounds + and leave them out. Benchmarks taken while a build runs are worthless. + +## Known hot spots / lessons +- **Audio thread vs game thread:** the game thread waits for the runtime lock while a block renders. Keep real-time + paths lock-free, allocation-free and log-free. +- **Logging in hot paths** (per-post `info!`, per-frame trace lines) costs real time; gate it, and write logs from a + background thread. +- **Bevy/rodio: never `PlaybackSettings::LOOP` an endless custom source:** LOOP = `repeat_infinite()` -> + `Buffered`, hundreds of ms of audio per device callback under the runtime lock. `ONCE` plays it forever with one + block per pull. +- **Synchronous file I/O + decode on the game thread** (bank loads on first approach: multi-ms frames). Fixed by a + distance prefetch. Pattern for moving I/O off the game thread: + - Move only the pure part (read + parse + decode); the state change stays at its old call site. Identity then + reduces to "same bytes in" plus "the new per-frame code touches no runtime / RNG state". + - Never delay the consumer: the slot is done (take), running (wait, never longer than doing it here) or queued / + failed (load here as before). + - Size the memory first, and count where the cost moved (freeing big buffers belongs on the worker too). + - Prove "no work on this thread" with a `cfg(test)` thread-local counter in the decode helper. +- **Setup speed:** threads only pay where the work releases the GIL (zlib, file I/O); prefetching npz arrays on + threads was slower. +- **Recomp research hooks** on an audio render thread can delay the game's command-queue drain until it overflows: + keep hooks light (skill `recomp-research`). diff --git a/tools/claude-skills/optimisation/tools/audio_timing_summary.py b/tools/claude-skills/optimisation/tools/audio_timing_summary.py new file mode 100644 index 00000000..41ddc358 --- /dev/null +++ b/tools/claude-skills/optimisation/tools/audio_timing_summary.py @@ -0,0 +1,37 @@ +"""Summarise the AUDIO_TIMING lines of real play (SKATE_AUDIO_TIMING=1) per audio system: lines, the average +of the per-second averages, the per-second maximum (median / p90 / worst) and calls per second; then the render's +load per block (`block_voices` / `block_instances` / `block_grains`: mixer voices, AEMS instances, grain voices). + + py -3.13 .claude/skills/optimisation/tools/audio_timing_summary.py logs/game-YYYYMMDD-HHMMSS.stderr.log [...] +""" +import re +import statistics +import sys + +PAT = re.compile(r'(\w+)=(\d+)/(\d+)us×(\d+)') +GAUGE = re.compile(r'(\w+)=(\d+)/(\d+\.\d)×(\d+)') + + +def main(): + for f in sys.argv[1:]: + stats, gauges = {}, {} + for line in open(f, encoding='utf-8', errors='replace'): + if 'AUDIO_TIMING' not in line: + continue + for name, mx, avg, n in PAT.findall(line): + stats.setdefault(name, []).append((int(mx), int(avg), int(n))) + for name, mx, avg, n in GAUGE.findall(line): + gauges.setdefault(name, []).append((int(mx), float(avg), int(n))) + print(f) + for k, v in stats.items(): + mx = sorted(x[0] for x in v) + print(f' {k:16s} lines {len(v):4d} avg {statistics.mean(x[1] for x in v):8.1f} us per-second max: median ' + f'{mx[len(mx) // 2]:6d} p90 {mx[int(len(mx) * .9)]:6d} worst {mx[-1]:7d} calls/s {statistics.mean(x[2] for x in v):7.1f}') + for k, v in gauges.items(): + mx = sorted(x[0] for x in v) + print(f' {k:16s} lines {len(v):4d} per block avg {statistics.mean(x[1] for x in v):6.1f} per-second max: median ' + f'{mx[len(mx) // 2]:4d} p90 {mx[int(len(mx) * .9)]:4d} worst {mx[-1]:4d}') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/optimisation/tools/compare_runs.sh b/tools/claude-skills/optimisation/tools/compare_runs.sh new file mode 100644 index 00000000..03c5202f --- /dev/null +++ b/tools/claude-skills/optimisation/tools/compare_runs.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Compare two e2e_bench.sh runs: output hashes (must be identical) and the timing summaries. +# .claude/skills/optimisation/tools/compare_runs.sh BASE NEW +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +R="$ROOT/.local/audio-re/opt/runs" +status=0 +for d in "$R/$2"/*/; do + s=$(basename "$d") + if [ ! -f "$R/$1/$s/hashes.txt" ]; then echo "$s: no baseline"; continue; fi + if diff -q "$R/$1/$s/hashes.txt" "$d/hashes.txt" > /dev/null; then + echo "$s: IDENTICAL ($(wc -l < "$d/hashes.txt") files)" + else + echo "$s: DIFFERENT"; diff "$R/$1/$s/hashes.txt" "$d/hashes.txt" | head -20; status=1 + fi +done +py -3.13 "$ROOT/.claude/skills/optimisation/tools/timing_summary.py" "$R/$1" "$R/$2" +exit $status diff --git a/tools/claude-skills/optimisation/tools/e2e_bench.sh b/tools/claude-skills/optimisation/tools/e2e_bench.sh new file mode 100644 index 00000000..207095a3 --- /dev/null +++ b/tools/claude-skills/optimisation/tools/e2e_bench.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Native-audio optimisation bench (skill `optimisation`): renders the e2e scenarios and real-play +# replays headless, records per-frame / per-block timing, and hashes every output. +# +# .claude/skills/optimisation/tools/e2e_bench.sh LABEL [MODE...] +# +# MODE: row (one host call per 60 Hz row, the default e2e), fpsN (the game's host at N fps: +# E2E_FPS=N, console MixMap cadence; e.g. fps30, fps300, fps1000). Default: row fps300. +# Inputs: .local/audio-re/opt/scen/*.tsv (the 13 e2e scenarios) and .local/audio-re/opt/real/*.tsv +# (whole play sessions via `scenarios.py --from-log LOG --cut r0-999999 NAME`). +# Output: .local/audio-re/opt/runs/LABEL/-/ with timing.txt and hashes.txt +# (sha256 of every .ours.f32 and .ours.voices.tsv). KEEP=1 keeps the .f32 renders (else deleted +# after hashing, except for LABEL=base). Compare two labels with compare_runs.sh A B. +# Uses its own target dir (.local/opt-target) so it never waits on, or invalidates, the main build. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.local/opt-target}" +LABEL="$1"; shift +MODES=("$@"); [ ${#MODES[@]} -eq 0 ] && MODES=(row fps300) +for set in scen real; do + for mode in "${MODES[@]}"; do + out="$ROOT/.local/audio-re/opt/runs/$LABEL/$set-$mode" + rm -rf "$out"; mkdir -p "$out" + cp "$ROOT/.local/audio-re/opt/$set/"*.tsv "$out/" + envs=(E2E_DIR="$out" E2E_TIMING=1) + case "$mode" in fps*) envs+=(E2E_FPS="${mode#fps}");; esac + (cd "$ROOT" && env "${envs[@]}" cargo test -q -p skate-game --release --bin skate3rust --locked -- --ignored --exact game_audio::e2e::e2e_render --nocapture) > "$out/timing.txt" 2>&1 + (cd "$out" && sha256sum *.ours.f32 *.ours.voices.tsv > hashes.txt) + if [ "$LABEL" != base ] && [ "${KEEP:-0}" != 1 ]; then rm -f "$out"/*.ours.f32; fi + echo "$LABEL $set-$mode: $(wc -l < "$out/hashes.txt") outputs hashed" + done +done diff --git a/tools/claude-skills/optimisation/tools/frame_log_summary.py b/tools/claude-skills/optimisation/tools/frame_log_summary.py new file mode 100644 index 00000000..f2876695 --- /dev/null +++ b/tools/claude-skills/optimisation/tools/frame_log_summary.py @@ -0,0 +1,73 @@ +"""Summarise a SKATE_FRAME_LOG file (frame log v1). + + py -3.13 frame_log_summary.py LOG.tsv [--hitches N] + +Prints frame-time percentiles, the per-second worst frame (median / p90 / max), +how often physics had to catch up (fixed_steps > 1), the main-thread CPU share, +and the N longest hitches with their UTC wall time, to line up with +logs\\game-*.stderr.log and the audio state log. Rejects the whole file on a +malformed row (bad data is deleted, not parsed around). +""" +import argparse, datetime, math, sys +from collections import defaultdict + +MAGIC = '# skate3rust frame log v1' +COLUMNS = ['wall_unix_s', 'frame', 'frame_ms', 'fixed_steps', 'fixed_ms', 'main_ms', 'hitch', 'median_ms'] + + +def pct(sorted_values, p): + if not sorted_values: + return 0.0 + rank = math.ceil(p * len(sorted_values) / 100 - 1e-9) + return sorted_values[min(max(rank, 1), len(sorted_values)) - 1] + + +def load(path): + rows = [] + with open(path, encoding='utf-8') as f: + if f.readline().rstrip('\n') != MAGIC or f.readline().rstrip('\n').split('\t') != COLUMNS: + sys.exit(f'{path}: not a frame log v1') + for number, line in enumerate(f, 3): + fields = line.rstrip('\n').split('\t') + if len(fields) != len(COLUMNS) or fields[6] not in ('', 'HITCH'): + sys.exit(f'{path}:{number}: malformed row; delete the file') + rows.append(dict(wall=float(fields[0]), frame=int(fields[1]), ms=float(fields[2]), + steps=int(fields[3]), fixed_ms=float(fields[4]), main_ms=float(fields[5]), + hitch=fields[6] == 'HITCH', median=float(fields[7]) if fields[7] else None)) + return rows + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('log') + p.add_argument('--hitches', type=int, default=15) + a = p.parse_args() + rows = load(a.log) + if not rows: + sys.exit('no frames') + ms = sorted(r['ms'] for r in rows) + span = rows[-1]['wall'] - rows[0]['wall'] + print(f'{len(rows)} frames over {span:.1f} s ({len(rows) / max(span, 1e-9):.1f} fps mean)') + print('frame ms: ' + ' '.join(f'p{q}={pct(ms, q):.2f}' for q in (50, 90, 99, 99.9)) + f' max={ms[-1]:.1f}') + per_second = defaultdict(float) + for r in rows: + per_second[int(r['wall'])] = max(per_second[int(r['wall'])], r['ms']) + worst = sorted(per_second.values()) + print(f'per-second worst frame: median {pct(worst, 50):.1f} p90 {pct(worst, 90):.1f} max {worst[-1]:.1f} ms; ' + f'seconds with a frame > 50 ms: {sum(v > 50 for v in worst)}') + catch_up = sum(r['steps'] > 1 for r in rows) + print(f'frames with physics catch-up (fixed_steps > 1): {catch_up} ({100 * catch_up / len(rows):.1f} %)') + main = sorted(r['main_ms'] for r in rows) + print(f'main-thread CPU ms: p50 {pct(main, 50):.2f} p99 {pct(main, 99):.2f}; ' + f'fixed (physics) ms p50 {pct(sorted(r["fixed_ms"] for r in rows), 50):.2f}') + hitches = sorted((r for r in rows if r['hitch']), key=lambda r: -r['ms']) + print(f'{len(hitches)} hitches (> 2x median); longest:') + for r in hitches[:a.hitches]: + when = datetime.datetime.fromtimestamp(r['wall'], datetime.timezone.utc).strftime('%H:%M:%S.%f')[:-3] + bound = 'main thread' if r['main_ms'] > 0.5 * r['ms'] else 'render/GPU/present or OS' + print(f' {when} UTC frame {r["frame"]:>7} {r["ms"]:7.1f} ms (median {r["median"]:.1f}) ' + f'main {r["main_ms"]:.1f} ms, physics {r["fixed_ms"]:.1f} ms x{r["steps"]} -> {bound}') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/optimisation/tools/timing_summary.py b/tools/claude-skills/optimisation/tools/timing_summary.py new file mode 100644 index 00000000..fc026f9e --- /dev/null +++ b/tools/claude-skills/optimisation/tools/timing_summary.py @@ -0,0 +1,42 @@ +"""Summarise e2e_bench.sh timing: per run dir, the game-thread call and render-block times pooled +over every scenario (p50 / p90 / p99 / p99.9 / max, µs, and the total), side by side for each +run given. + + py -3.13 .claude/skills/optimisation/tools/timing_summary.py RUN_DIR [RUN_DIR...] +""" +import sys +from pathlib import Path + + +def load(d: Path): + out = {} + for sub in sorted(p for p in d.iterdir() if p.is_dir()): + kinds = {'frame': [], 'block': []} + for f in sub.glob('*.ours.timing.tsv'): + for line in f.read_text().splitlines()[1:]: + k, us = line.split('\t') + kinds[k].append(float(us)) + out[sub.name] = kinds + return out + + +def q(v, p): + return v[min(len(v) - 1, int((len(v) - 1) * p))] + + +def main(): + runs = [(Path(a).name, load(Path(a))) for a in sys.argv[1:]] + sets = sorted({s for _, r in runs for s in r}) + for s in sets: + for kind in ('block', 'frame'): + print(f'{s} {kind}:') + for name, r in runs: + v = sorted(r.get(s, {}).get(kind, [])) + if not v: + continue + print(f' {name:>16}: n {len(v):6d} p50 {q(v, .5):7.1f} p90 {q(v, .9):7.1f} p99 {q(v, .99):7.1f}' + f' p99.9 {q(v, .999):7.1f} max {v[-1]:8.1f} sum {sum(v) / 1e3:9.1f} ms') + + +if __name__ == '__main__': + main() diff --git a/tools/claude-skills/prior-work-check/SKILL.md b/tools/claude-skills/prior-work-check/SKILL.md new file mode 100644 index 00000000..5d3ea26f --- /dev/null +++ b/tools/claude-skills/prior-work-check/SKILL.md @@ -0,0 +1,49 @@ +--- +name: prior-work-check +description: Before starting a feature, fix or investigation, check whether upstream (SK8-ENGINE/skate-3-rust-engine) or related projects already did it - open/closed PRs, issues, branches, forks, and sibling projects like skate3recomp. Use at the start of any new piece of work, when planning, or when asked "has someone done this already?". +--- + +# Check for prior work before starting + +Why: audio work was once started from scratch while upstream PR #4 "Audio/retail exact player sound" and #1 +"audio engine vehicles" already existed, and several "known pre-existing test failures" already had fix PRs +upstream (#17, #18, #19). Ten minutes of searching first avoids duplicate work and gives format knowledge, sample +choices and test fixes for free. + +## 1. Upstream PRs and issues (always) +```bash +R=SK8-ENGINE/skate-3-rust-engine +gh pr list -R $R --state all --limit 100 --json number,title,state,author,updatedAt \ + --jq '.[] | "#\(.number) [\(.state)] \(.title) (\(.author.login), \(.updatedAt[:10]))"' +gh issue list -R $R --state all --limit 100 --json number,title,state --jq '.[] | "#\(.number) [\(.state)] \(.title)"' +gh search prs --repo $R "" # e.g. audio sound sfx xma | water | gamepad +gh search issues --repo $R "" +``` +Read every hit's title; for anything related, read the PR description, the file list +(`gh pr view N -R $R --json files --jq '.files[].path'`) and review comments. Closed/unmerged PRs count too +(rejected approaches, maintainer feedback). + +## 2. Your own fork and branches +- `gh pr list -R --state all`, local `git branch -a`, your todo / notes folders: something may be + half-done on another branch. + +## 3. Wider search (for features and format/reverse-engineering work) +- Other forks of upstream: `gh api repos/SK8-ENGINE/skate-3-rust-engine/forks --paginate --jq '.[] | + "\(.full_name) \(.pushed_at)"'`; check recently pushed ones for matching branches + (`gh api repos//branches --jq '.[].name'`). +- Sibling projects: `mchughalex/skate3recomp` (static recompilation; check its licence before reusing anything: + without a licence it is read/run only, never copy code; see skill `recomp-audio-trace`). + `gh search repos "skate 3" --limit 30`, `gh search code ""` (for example `SPLC`, + `ABKC`, `MOIR` for EA audio). +- Format tools: vgmstream (EA SNR/SNS/ABK support), Xenia/Xbox modding wikis for EA formats. + +## 4. Report before starting +Report briefly: what exists (link, state, author, date), how complete it looks, what can be reused or learned +(licence permitting: upstream code is under the project's own licence; repos without a licence are reference-only), +and whether starting fresh, building on it, or reviewing/testing it is the better move. If a substantial existing +PR covers the work, let the person you work for decide. + +## Notes +- Read-only: never comment on, approve, or push to someone else's PR as part of this check. +- Record findings in your plan/todo for the work, including dependencies on and overlaps with other open PRs. +- Credit every project whose findings you use, in docs and PR descriptions. diff --git a/tools/claude-skills/recomp-audio-trace/SKILL.md b/tools/claude-skills/recomp-audio-trace/SKILL.md new file mode 100644 index 00000000..a306c628 --- /dev/null +++ b/tools/claude-skills/recomp-audio-trace/SKILL.md @@ -0,0 +1,98 @@ +--- +name: recomp-audio-trace +description: Build and run a local skate3recomp (static recompilation of Skate 3) with an audio trace to measure which sound banks/samples retail plays for gameplay events. Use when continuing retail-sound measurement work or rebuilding that local research build. +--- + +# Local skate3recomp audio trace (research only) + +Credits: skate3recomp (https://github.com/mchughalex/skate3recomp, the static recompilation), its rexglue SDK, and +Xenia (from which rexglue's runtime pieces derive). Credit them in every doc and PR that uses measurements from them. + +## Rules: read first +- **You need your own legally owned copy of Skate 3** and must build the recomp yourself. Nothing from the game + (code, data, disassembly, decoded audio) is ever shared or committed. +- skate3recomp has **no licence**: local research only. Never copy its code (or its Xenia-derived rexglue SDK) into + skate-3-rust-engine. Only measurements (bank names, sample indices, timings, addresses) come back, cited as + "measured from a local recompiled build". +- Codegen output (`/generated`) is local. +- Run only one game instance at a time (the recomp is GPU/CPU heavy; don't run it beside a skate3rust session). + +## Layout +- Clone the recomp to `` with submodules. `third_party/rexglue-sdk` is a submodule; if a nested + module (for example `thirdparty/imgui` or `thirdparty/o1heap`) pins a commit that is not on GitHub, check out the + SDK's nearest earlier public pin, and if a nested module is empty after a failed recursive update, check it out + again individually. A debug-UI-only compile problem (for example gamma assignments in the imgui drawer) can be + commented out locally. + +## Title Update (required) +The recomp's own sources call recompiled functions by their **TU 3.0.3.0** addresses, so it does not build from the +base disc alone (codegen works; the `skate3` target then fails with `use of undeclared identifier 'sub_...'`). Put +the Title Update package from your own copy (title id 454108E6) in the recomp root, where CMake finds it +automatically ("title update codegen enabled"). It extracts `default.xexp` (and `data/webkit/EAWebkit.xexp`); the +expected hashes are in the recomp's title-update installer source. Exclude it from git locally +(`.git/info/exclude`: `TU_*`). If a TU-specific codegen patch anchor fails (for example the demo-path intro-movie +patch), make it non-fatal locally. + +## Toolchain helpers (keep them in a gitignored folder of your checkout, for example `.local/recomp/tools/`) +`recomp_env.bat`: +```bat +@echo off +rem Build environment for the local skate3recomp research build. +call "\VC\Auxiliary\Build\vcvars64.bat" >nul || exit /b 1 +set "PATH=\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin;\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja;\bin;%PATH%" +cd /d "" || exit /b 1 +%* +``` +`recomp_configure.bat`: +```bat +@echo off +rem GNU-style clang driver: the SDK's flags (-fno-char8_t, -ffp-model=strict, -mcmodel) are ignored by clang-cl. +call "%~dp0recomp_env.bat" cmake --preset relwithdebinfo -B out/build/clang-relwithdebinfo -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_RC_COMPILER=llvm-rc "-DSKATE3_GAME_DATA_ROOT=" +exit /b %errorlevel% +``` +**Use `clang`/`clang++`, not `clang-cl`.** With clang-cl every SDK file logs `unknown argument ignored in clang-cl: +'-fno-char8_t'` and `'-ffp-model=strict'` (strict floating point matters for recompiled code), and a `u8"..."` +literal fails (`char8_t`). +`recomp_build.bat` (argument = target): +```bat +@echo off +call "%~dp0recomp_env.bat" cmake --build out/build/clang-relwithdebinfo --target %1 +exit /b %errorlevel% +``` +Without `exit /b %errorlevel%` a failed ninja run still reports exit 0; check the log for `FAILED:` / `): error:` +either way. `SKATE3_GAME_DATA_ROOT` is read only by codegen (translating `default.xex` from your extracted disc). +Run the .bat files through PowerShell or cmd from the recomp folder; Git Bash's `cmd //c` breaks on spaces in paths. + +## Build flow +1. `recomp_configure.bat` (warns `generated/sources.cmake not found` the first time; expected). +2. `recomp_build.bat generate-all > 2>&1`: builds the rexglue tool and translates `default.xex` into + `generated/` (long; run it in the background). +3. `recomp_configure.bat` again (now picks up `generated/sources.cmake`). +4. `recomp_build.bat skate3`. Never rebuild while `skate3.exe` runs (the exe is locked). +Check failures with `grep -m5 " error:\|FAILED" `. + +## Instrumentation +- A header-only trace writer in the SDK (`rex/audio/audio_trace.h`), active only when env `SKATE3_AUDIO_TRACE_FILE` + names an output file; tab-separated, first field after the kind = ms since start: + - `READ ` (hook in the kernel's `NtReadFile` after the read); + - `XMA ` (hook where the XMA context reads its input + buffer address). +- Gameplay-side hooks (POST, SPLC, PLAY/GAIN/SEND/MOD, world one-shots, ambience, ...) live in the recomp's + `src/research/hooks_*.cpp` with a shared `trace_common.h`: see skill `recomp-research` for how to write them and + skill `recomp-scripted-runs` for the trace line formats. Rebuild after editing hooks. +- A published set of these research hooks exists as a `research-hooks` branch of a skate3recomp fork; the + `tools/recomp-code-search/README.md` of upstream PR #37 links it. + +## Analysis +1. From `READ` lines on `data/audio/*.big`: which bank file range was loaded at which guest address range. +2. Each `XMA` input address -> containing loaded bank -> offset in the bank -> SNR stream index, using the scanner in + `tools/asset_pipeline/audio_formats.py` (`scan_snr` / `splc_streams`, audio branches) on the same bank (from the + BIG via `tools/owned_game/big.py`). The index matches `audio_manifest.json` bank indices for exported banks. +3. Fallback if EA copies packets to a separate buffer: match the packet bytes against bank data. +4. Find EA's AEMS post-event function from call stacks to log event ids; name them from the `.csi` MOIR records + (16 bytes: name offset, 16-bit id, default value). +5. Port the mechanism (skill `aems-port`), then have a person confirm by ear (skill `audio-tuning`). + +## Unattended sessions +Use skill `recomp-scripted-runs` (scripted pad input, recorded routes, background mode, window screenshots) instead +of hand-marked sessions. diff --git a/tools/claude-skills/recomp-research/SKILL.md b/tools/claude-skills/recomp-research/SKILL.md new file mode 100644 index 00000000..3c62bba3 --- /dev/null +++ b/tools/claude-skills/recomp-research/SKILL.md @@ -0,0 +1,137 @@ +--- +name: recomp-research +description: Reverse-engineer any part of retail Skate 3 (NPCs, traffic, AI and aggression, triggers and teleport volumes, physics, audio, scripting...) by finding the code in the TU3 recompilation, hooking it with guarded trace hooks, driving the game with scripted runs and analysing the trace, then porting the behaviour to the Rust engine. Use whenever the engine needs retail behaviour that isn't documented yet. +--- + +# Recomp research: find -> hook -> run -> analyse -> port + +Related skills: `recomp-audio-trace` (build, layout, title update), `recomp-scripted-runs` (pad scripts, teleports, +batches, screenshots), `aems-port` (an example of a full port), `prior-work-check` (always first). + +Credits: skate3recomp (mchughalex), its rexglue SDK, and Xenia. Credit them in every doc and PR that uses findings. + +## Rules +- **Your own copy, your own build:** you need a legally owned copy of Skate 3 and must build the recomp yourself + (skill `recomp-audio-trace`). Nothing from the game is shared or committed. +- **Reference only:** skate3recomp has no licence. Never copy generated or recomp code into the engine; describe + behaviour in your own words (addresses, constants and offsets are facts and fine). Disassembly stays local. +- **Retail code is the source of truth.** The recomp is not the console: its frame rate, threading and timing differ. + Trust its logic and values; treat its timings with care. Label every ported value with where it came from + ([code] address, [data] record, [trace] session). +- **One game at a time:** at most one recomp or one short skate3rust test. Never build the recomp while it runs. +- **Clean up:** stop processes and remove large temporary output; `SKATE_REPORT_CHILD=1` for skate3rust runs. +- **Scripts live in the repo** (a gitignored `.local/research//` for data, a skill's `tools/` for reusable + scripts), never only in a temporary scratch folder. +- **Guard every hook read** with `Readable()` (an SEH page probe). An unguarded read through an inferred pointer + crashes the recomp. +- **Scripts are open-loop:** they can teleport and act in place, but cannot steer to a target (a "wander" script got + stuck in a corner). Anything that involves reaching NPCs or places needs a passive session recorded while a person + plays. +- **Confirm the mechanic exists in Skate 3** before chasing it: some systems (for example security guards) are + Skate 2 leftovers that Skate 3 does not use. Verify claims either way with a trace. + +## 1. Find the code +- **Strings, RTTI and descriptors** in a dump of the TU3 memory image (big-endian, base 0x82000000) made from your + own copy: class names (`CSTATE_*`, `SFXObj_*`, ...), debug strings, vtables. Then find the code that uses them in + `/generated/skate3_recomp.*.cpp` (`DEFINE_REX_FUNC(sub_X)`). +- **Addresses in code:** `lis rN,` + `addi`/`lwz `. The high half is sign-extended: `lis -31992` = 0x8308, + `lis -31987` = 0x830D. Globals live at 0x8308xxxx / 0x830Cxxxx; easy to confuse with 0x8208/0x820C. Large offsets + split as `addis rX,rY,3` + `-3920`, i.e. 0x2F0B0. The low half is signed too: check every hi<<16 + lo sum twice. +- **Code search tools:** `tools/fn.sh ADDR` here (dump a function's asm comments; needs `RECOMP_GENERATED`), + `tools/fnstrings.py ADDR` (data addresses a function materialises and the string at each; needs `PPC_IMAGE`), and + `grepfn.sh`, `callctx.sh`, `ppcxref.py` from `tools/recomp-code-search/` of upstream PR #37. +- PowerPC `bdzf` = decrement CTR, branch if CTR == 0 and the condition is false (switch idiom); reading it as + "CTR != 0" swaps the cases. +- **Data:** the attribute database (`assets/private/stock/skater-collections.json` in your converted install; keys + are `name_id` = lookup8 of the name), world-painter region layers (`tools/asset_pipeline/audio_formats + .region_layers` on audio branches: districts, census, challenge zones...), streams via `skate3_streams.read_sfil`. +- **Delegate wide searches** to background agents with a precise brief (sources, addresses, the "own words" rule, + output paths). Several agents can map different subsystems in parallel. + +## 2. Hook it +- **Where hooks live:** the recomp's `src/research/`: + - `trace_common.h`: guarded reads `Readable`/`TryU32`/`TryU64`/`TryF32`/`Vec3Text`, `CallerChain`, `NameAt`, + `HexAt`, the category gate `On("")`, and `FIRST_PASS_HOOK`; + - `hooks_.cpp` per domain (audio, physics, watch, world, npc, traffic, vehicles, marker, queue), added to + the common sources list in `CMakeLists.txt` (reconfigure after adding one). + - A published set exists as a `research-hooks` branch of a skate3recomp fork (linked from upstream PR #37's + `tools/recomp-code-search/README.md`). If you publish your own: no game code or data, no private paths, SDK + changes as a patch file, a README kept current. +- **Hook shape:** `extern "C" REX_FUNC(sub_X) { if (On("npc")) {...} __imp__sub_X(ctx, base); ... }`. Include + `"trace_common.h"` and `using namespace skate3_research;`. Arguments: r3-r10 / f1-f4 on entry; read struct fields + AFTER calling through for outputs. +- **Unknown argument meanings: first-pass hooks.** Log raw r3-r8/f1-f2 on entry, r3/f1 after the call and the + callers, rate-limited per function: one line `FIRST_PASS_HOOK(addr, "", "", "tag\t")`. Summarise + with `tools/first_pass.py` (distinct values per field: constant = object/flag, varying = data). Then name the + fields. Faster than decoding every argument statically. Write C++ with an editor tool, not shell/Python heredocs + (escapes like `\t` turned into real tabs or literal characters several times). +- **Hooking a shared helper for one caller:** check `ctx.lr` against the return address after the `bl` (find + `ctx.lr = 0x...;` before the call in the generated code). Non-volatile registers (r14-r31) still hold the caller's + values after the call. Before filtering an argument by address range, check every caller's `addi rN,r1,...`: + callers may pass STACK copies instead of the global table. +- **Read flags at the width the game reads them** (`lbz` = byte). Reading a word where the game reads a byte once + let NPC skaters through a local-player test. The reliable local-rider test for player audio objects is the byte + `[[obj+28]+72]` ("local72"); NPC skaters' components have 0 there. +- **One writer per process:** the exe and the runtime DLL both contain the trace header; let the first module own + the file (named-mutex election), open it in binary append, run the only writer thread and publish a plain C + `append` function pointer the other module calls. No STL/heap across modules. Never paper over malformed lines in + parsers; fix the writer. +- **Light mode:** format into a local buffer and queue; a background thread writes and flushes every 100 ms. `On()` + is a table parsed once. Rate-check BEFORE any guarded read or `CallerChain`; never hook a function called + thousands of times a second unless the work after the rate check is tiny; prefer one sampled read per frame over + hooking hot getters. +- **Audio command queue:** hooks on the audio render thread run inside the queue consumer's lock; keep them + lock-free and light. Heavy tracing there can overflow the game's audio command queue; growing the queue while + tracing and logging its fill level helps. +- **Categories:** `SKATE3_TRACE=audio,audiox,dsp,dspmod,physics,world,npc,traffic,aiskater,skitch,watch` (unset = + all). Choose only what a run needs: DSP/MOD and physics lines alone produce millions of lines per long batch. + High-volume per-frame detail goes in its own category (`audiox`) so normal sessions stay light. +- **Extending a kind:** add a NEW kind instead of changing an existing kind's layout, so readers stay stable. Every + kind gets a fixed field count in `tools/trace.py` `FIELD_COUNTS`; `py -3.13 tools/trace.py ` prints + malformed counts per kind (must be 0). Document every kind in the hook file header and in skill + `recomp-scripted-runs`. +- **Proof of installation:** have each new hook write one `HOOKARMED` line on its first call, and check it fires in + a short background run of your own before asking anyone to record a session. +- **Watch list (no rebuild):** `SKATE3_WATCH=` with `