From a784ccec743f137c21ac0a58c857add8242c5455 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 00:09:40 -0400 Subject: [PATCH 01/18] WIP: Valorant replay viewer on a native vrfkit decoder Checkpoint of in-progress work; not for review yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 41 + docs/adr/0006-replay-decoder.md | 48 + docs/replay-format.md | 180 ++ lib/const/settings.dart | 5 + lib/providers/replay_library_provider.dart | 66 + lib/replay/replay_ability_catalog.dart | 525 ++++ lib/replay/replay_agents.dart | 39 + lib/replay/replay_capture.dart | 70 + lib/replay/replay_decoder.dart | 83 + lib/replay/replay_decoder_io.dart | 148 + lib/replay/replay_decoder_stub.dart | 15 + lib/replay/replay_document.dart | 633 +++++ lib/replay/replay_files.dart | 187 ++ lib/replay/replay_frame.dart | 228 ++ lib/replay/replay_loader.dart | 42 + lib/replay/replay_map_projection.dart | 158 ++ lib/replay/replay_playback.dart | 115 + lib/replay/replay_weapons.dart | 34 + lib/replay_view.dart | 346 +++ lib/widgets/folder_navigator.dart | 42 +- lib/widgets/library_title_strip.dart | 93 +- lib/widgets/page_transition_overlay.dart | 199 +- lib/widgets/replay/replay_canvas.dart | 151 + lib/widgets/replay/replay_dock.dart | 336 +++ .../replay/replay_library_content.dart | 450 +++ lib/widgets/replay/replay_match_card.dart | 232 ++ lib/widgets/replay/replay_roster.dart | 316 +++ macos/Runner.xcodeproj/project.pbxproj | 15 + native/replay/.gitignore | 1 + native/replay/CMakeLists.txt | 27 + native/replay/Cargo.lock | 313 +++ native/replay/Cargo.toml | 55 + native/replay/NOTICE.md | 81 + native/replay/build_macos.sh | 36 + native/replay/include/icarus_replay.h | 58 + native/replay/src/analysis/casts.rs | 221 ++ native/replay/src/analysis/kills.rs | 232 ++ native/replay/src/analysis/mod.rs | 396 +++ native/replay/src/analysis/movement.rs | 122 + native/replay/src/analysis/rounds.rs | 452 +++ native/replay/src/analysis/utility.rs | 176 ++ native/replay/src/analysis/vitals.rs | 178 ++ native/replay/src/bin/icarus-replay-dump.rs | 298 ++ native/replay/src/collect.rs | 153 ++ native/replay/src/container.rs | 152 ++ native/replay/src/decode.rs | 244 ++ native/replay/src/document.rs | 172 ++ native/replay/src/error.rs | 64 + native/replay/src/ffi.rs | 156 ++ native/replay/src/fieldpath.rs | 100 + native/replay/src/guard.rs | 127 + native/replay/src/header.rs | 230 ++ native/replay/src/lib.rs | 115 + native/replay/src/oodle.rs | 304 +++ native/replay/src/vendor/mod.rs | 17 + native/replay/src/vendor/pass.rs | 98 + native/replay/src/vendor/sink/blobs.rs | 2424 +++++++++++++++++ .../replay/src/vendor/sink/failure_stats.rs | 409 +++ native/replay/src/vendor/sink/intern.rs | 110 + .../replay/src/vendor/sink/measured_routes.rs | 283 ++ native/replay/src/vendor/sink/mod.rs | 718 +++++ native/replay/src/vendor/sink/paths.rs | 813 ++++++ native/replay/src/vendor/sink/rpc.rs | 762 ++++++ native/replay/src/vendor/sink/stream.rs | 2062 ++++++++++++++ native/replay/tests/corpus.rs | 215 ++ test/replay_ability_catalog_test.dart | 671 +++++ test/replay_document_test.dart | 181 ++ test/replay_files_test.dart | 84 + test/replay_map_projection_test.dart | 228 ++ windows/CMakeLists.txt | 4 + 70 files changed, 18208 insertions(+), 131 deletions(-) create mode 100644 docs/adr/0006-replay-decoder.md create mode 100644 docs/replay-format.md create mode 100644 lib/providers/replay_library_provider.dart create mode 100644 lib/replay/replay_ability_catalog.dart create mode 100644 lib/replay/replay_agents.dart create mode 100644 lib/replay/replay_capture.dart create mode 100644 lib/replay/replay_decoder.dart create mode 100644 lib/replay/replay_decoder_io.dart create mode 100644 lib/replay/replay_decoder_stub.dart create mode 100644 lib/replay/replay_document.dart create mode 100644 lib/replay/replay_files.dart create mode 100644 lib/replay/replay_frame.dart create mode 100644 lib/replay/replay_loader.dart create mode 100644 lib/replay/replay_map_projection.dart create mode 100644 lib/replay/replay_playback.dart create mode 100644 lib/replay/replay_weapons.dart create mode 100644 lib/replay_view.dart create mode 100644 lib/widgets/replay/replay_canvas.dart create mode 100644 lib/widgets/replay/replay_dock.dart create mode 100644 lib/widgets/replay/replay_library_content.dart create mode 100644 lib/widgets/replay/replay_match_card.dart create mode 100644 lib/widgets/replay/replay_roster.dart create mode 100644 native/replay/.gitignore create mode 100644 native/replay/CMakeLists.txt create mode 100644 native/replay/Cargo.lock create mode 100644 native/replay/Cargo.toml create mode 100644 native/replay/NOTICE.md create mode 100644 native/replay/build_macos.sh create mode 100644 native/replay/include/icarus_replay.h create mode 100644 native/replay/src/analysis/casts.rs create mode 100644 native/replay/src/analysis/kills.rs create mode 100644 native/replay/src/analysis/mod.rs create mode 100644 native/replay/src/analysis/movement.rs create mode 100644 native/replay/src/analysis/rounds.rs create mode 100644 native/replay/src/analysis/utility.rs create mode 100644 native/replay/src/analysis/vitals.rs create mode 100644 native/replay/src/bin/icarus-replay-dump.rs create mode 100644 native/replay/src/collect.rs create mode 100644 native/replay/src/container.rs create mode 100644 native/replay/src/decode.rs create mode 100644 native/replay/src/document.rs create mode 100644 native/replay/src/error.rs create mode 100644 native/replay/src/ffi.rs create mode 100644 native/replay/src/fieldpath.rs create mode 100644 native/replay/src/guard.rs create mode 100644 native/replay/src/header.rs create mode 100644 native/replay/src/lib.rs create mode 100644 native/replay/src/oodle.rs create mode 100644 native/replay/src/vendor/mod.rs create mode 100644 native/replay/src/vendor/pass.rs create mode 100644 native/replay/src/vendor/sink/blobs.rs create mode 100644 native/replay/src/vendor/sink/failure_stats.rs create mode 100644 native/replay/src/vendor/sink/intern.rs create mode 100644 native/replay/src/vendor/sink/measured_routes.rs create mode 100644 native/replay/src/vendor/sink/mod.rs create mode 100644 native/replay/src/vendor/sink/paths.rs create mode 100644 native/replay/src/vendor/sink/rpc.rs create mode 100644 native/replay/src/vendor/sink/stream.rs create mode 100644 native/replay/tests/corpus.rs create mode 100644 test/replay_ability_catalog_test.dart create mode 100644 test/replay_document_test.dart create mode 100644 test/replay_files_test.dart create mode 100644 test/replay_map_projection_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc64a39a5..4f9d94572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,47 @@ on: pull_request: jobs: + native-replay: + runs-on: ubuntu-latest + defaults: + run: + working-directory: native/replay + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/replay/target + key: ${{ runner.os }}-cargo-replay-${{ hashFiles('native/replay/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-replay- + + # oozextract is GPL-derived: it must never enter the build + # (docs/adr/0006-replay-decoder.md). + - name: Keep oozextract out + run: | + if grep -q oozextract Cargo.lock; then + echo "::error::oozextract is in native/replay/Cargo.lock" + exit 1 + fi + + - name: Clippy + run: | + cargo clippy --locked --all-targets -- -D warnings + cargo clippy --locked --all-targets --features dev-vectors -- -D warnings + + - name: Test + run: cargo test --locked + validate: runs-on: windows-latest env: diff --git a/docs/adr/0006-replay-decoder.md b/docs/adr/0006-replay-decoder.md new file mode 100644 index 000000000..890204ea9 --- /dev/null +++ b/docs/adr/0006-replay-decoder.md @@ -0,0 +1,48 @@ +# 6. Valorant replays are decoded by a native library built on vrfkit + +Status: accepted, 2026-10-01 + +## Context + +Valorant writes match replays as `.vrf` files: an Unreal local replay, its +replication payloads Oodle-compressed and scrambled by a transform Riot +rotates every patch or two. We spent June and July reverse engineering the +format in Node (`codex/valorant-replay-file-reverse-engineering`). That work +proved the layers and decoded movement for build 13.00, but it ran 30 to 300 +seconds per match, knew three builds, and shelled out to Node, which no user +has installed. + +Since then two open-source parsers have caught up and track every patch: +vrfkit (Rust, Apache-2.0, builds 11.06 to 13.06) and Michel Giehl's +ValorantReplayParser (C#, MIT, which vrfkit derives from). vrfkit decodes one +of our 13.00 replays in about a second with no decode errors. + +Every open-source Oodle decoder (powzix/ooz and its ports: ooz-wasm, +oozextract, OozSharp, oozle) is GPL-3.0 or derived from it, including the ones +labelled MIT. Valorant only uses a narrow Oodle subset: Mermaid/Selkie blocks +with raw, un-entropy-coded streams. + +## Decision + +- `native/replay` is a Rust library, loaded over FFI like `native/height`. + It depends on vrfkit's library crates pinned to a release tag, with the + `oodle` feature off, and vendors the CLI's packet pass with attribution. +- Its Oodle decoder is our own, written clean-room for the Selkie subset from + a facts-only format spec. ooz-derived code never enters the repo or the + build. Any block outside the subset fails loudly. +- The library emits Valorant facts only (subjects, agent UUIDs, class paths, + game-world centimetres) in the format described in + [`docs/replay-format.md`](../replay-format.md). Mapping those facts onto + Icarus agents, abilities and map coordinates is Dart's job, so the decoded + document stays readable by code that has never seen our widgets. +- A replay from an unknown build is refused, never guessed. Keeping up with + patches means bumping the vrfkit tag. + +## Consequences + +- Building the desktop app needs a Rust toolchain on Windows and macOS. + Unit tests that do not touch decoding stay pure Dart. +- New Valorant patches reach users when vrfkit registers the build and we + ship a release with the bumped tag. Until then that patch's replays show a + clear "not supported yet" state. +- The same crate can compile to WASM if the web app ever decodes replays. diff --git a/docs/replay-format.md b/docs/replay-format.md new file mode 100644 index 000000000..e549088a7 --- /dev/null +++ b/docs/replay-format.md @@ -0,0 +1,180 @@ +# Decoded replay format + +`native/replay` turns a Valorant `.vrf` into one decoded replay: a byte +buffer that Icarus caches on disk as `.icrp` and reads in Dart. +It holds Valorant facts only. Nothing in it names an Icarus agent, ability, +page, or widget; `lib/replay/` maps the facts onto Icarus. + +The buffer is a cache, rebuilt from the `.vrf` whenever `formatVersion` or +the decoder version changes. It is not part of the library and never goes +into an `.ica` file. + +## Container + +``` +offset type field +0 [u8; 4] magic "ICRP" +4 u32 LE formatVersion (1) +8 u32 LE jsonLength +12 [u8] JSON document, UTF-8, jsonLength bytes +... [u8] zero padding to the next multiple of 8 +... [u8] binary blob (movement records) +``` + +Offsets inside the JSON that point into the blob are relative to the blob's +first byte. + +## Conventions + +- Time is milliseconds on the replay timeline (`timeMs`), the clock that + Event chunks and DemoFrames share. 0 is the start of the recording. +- Positions are Unreal world centimetres `[x, y, z]`, exactly as replicated. +- `yaw` and `pitch` are degrees, Unreal convention: yaw 0 faces +X and grows + toward +Y; pitch is positive looking up. +- Players are keyed by `subject`, the account UUID from the replay header. +- Teams are Valorant's team ids as replicated: `"Red"` or `"Blue"`. +- Anything the decoder could not establish is absent or `null`, never + guessed. Optional fields are marked `?`. + +## Document + +```jsonc +{ + "decoder": { "name": "icarus_replay", "version": "0.1.0+vrfkit.0.2.5", "vrfkit": "0.2.5" }, + "match": { + "id": "dc078274-2d68-495b-8321-5e58b2a3eeba", + "mapPath": "/Game/Maps/Juliett/Juliett", + "build": "++Ares-Core+release-13.00", + "durationMs": 321499, + "recordedAt?": "2026-07-08T00:35:45Z", // the header's FDateTime ticks; no zone on the wire + "gameMode?": "/Game/GameModes/Bomb/BombGameMode.BombGameMode_C" + }, + "players": [ + { + "subject": "978b3946-…", + "agentId": "1dbf2edd-…", // valorant-api.com agent UUID; null if the header lacks it + "team": "Red", // null if no combat report names it + "name?": "…", // never set yet: the replay carries no display name + "characterGuids": [1206, 45530] // every body this player had, in order + } + ], + "rounds": [ + { + "index": 0, // 0-based, in play order + "startMs": 1203, // roundStarted event + "combatStartMs?": 31203, // barriers drop (the buy phase ends) + "endMs": 98000, // round decided; the replay's end if it never is + "attackingTeam": "Red", // null if no round of its half has a result + "winningTeam?": "Blue", + "endReason?": "elimination", // elimination | detonated | defused | time | surrender + "plant?": { "timeMs": 80000, "site?": "A", "subject?": "…", "position?": [x, y, z] }, + "defuse?": { "timeMs": 95000, "subject?": "…" }, + "detonateMs?": 125000, + "economy": [ // every player, at buy-phase end; empty without one + { + "subject": "…", + "credits": 3900, // null if no Money was replicated by then + "loadoutValue?": 4700, // never set yet: not replicated per player (13.00) + "weapon?": "…", // primary, else sidearm, equippable class path + "armor?": 50 // shield points of the armour worn: 25 or 50 + } + ] + } + ], + "kills": [ + { + "timeMs": 52010, + "victim": "…", + "killer?": "…", // absent for falls, spike and self-inflicted deaths + "assists": ["…"], + "damageSource?": "…" // weapon or ability class path when known + } + ], + "vitals": { + "": [[timeMs, health, armor], …] // one row per change; null until the wire states it + }, + "utility": [ + { + "id": 4211, // actor NetGUID, unique per open + "classPath": "/Game/Characters/Wraith/Ability_Wraith_4_Smoke…", + "owner?": "…", // subject + "spawnMs": 61000, + "endMs?": 76000, // actor closed + "position": [x, y, z], + "yaw?": 90.0, + "path?": [[timeMs, x, y, z], …], // later replicated positions (projectiles, moving walls) + "points?": [[x, y, z], …] // shape points (walls, paths drawn in-game) + } + ], + "casts": [ + { + "timeMs": 60500, + "subject": "…", + "slot": 4, // the replicated ability slot + "classPath?": "…", // the ability item; absent where the wire does not tie it + "position?": [x, y, z] + } + ], + "movement": { + "": { "offset": 0, "count": 18213 } + }, + "quality": { + "transformVerified": true, + "decodeErrors": 0, + "warnings": ["…"] + } +} +``` + +## Movement records + +Each player's movement is `count` records starting at `offset` in the blob, +sorted by time, 24 bytes each, little-endian: + +``` +i32 timeMs +f32 x +f32 y +f32 z +f32 yaw +f32 pitch +``` + +Records come from every body in the player's `characterGuids`. A body that +is parked off-map between lives (around x -50000, z -49900) is dropped, not +recorded. `yaw` is 0..360 as replicated; `pitch` is signed, -180..180. + +## Probe + +`icarus_replay_probe` reads the header alone, for a replay list, and returns: + +```jsonc +{ + "id": "dc078274-…", + "mapPath": "/Game/Maps/Juliett/Juliett", + "build": "++Ares-Core+release-13.00", + "durationMs": 321499, + "recordedAt?": "2026-07-08T00:35:45Z", + "supported": true, // a payload transform is registered for the build + "decoderVersion": "0.1.0+vrfkit.0.2.5", // same as the document's decoder.version + "players": [{ "subject": "…", "agentId": "…" }] +} +``` + +`decoderVersion` changes whenever the decoded output could: it is the crate +version plus the vrfkit tag. A cached `.icrp` decoded under another version +is stale. + +## Errors + +When decoding fails the library returns an error object instead of a +buffer: + +```json +{ "code": "unsupportedBuild", "message": "…", "build": "++Ares-Core+release-13.07" } +``` + +Codes: `notAReplay`, `unsupportedBuild`, `unsupportedCompression`, +`transformCheckFailed`, `corrupt`, `io`, `cancelled`. `build` is present +whenever the header was read. `transformCheckFailed` means the payloads +decoded into noise: the build's registered transform does not fit the file. diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 9a50eb895..24bd41a0e 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -99,6 +99,11 @@ class Settings { static const Color enemyOutlineColor = Color.fromARGB(139, 255, 82, 82); static const Color allyOutlineColor = Color.fromARGB(106, 105, 240, 175); + // The outline hues at full strength, for team text and marks on panels: + // scores, the roster, killfeed and timeline ticks. + static const Color allyInk = Color.fromARGB(255, 105, 240, 175); + static const Color enemyInk = Color.fromARGB(255, 255, 82, 82); + static Color neutralTeamShade(Color color) { return HSLColor.fromColor(color).withSaturation(0).toColor(); } diff --git a/lib/providers/replay_library_provider.dart b/lib/providers/replay_library_provider.dart new file mode 100644 index 000000000..51109b532 --- /dev/null +++ b/lib/providers/replay_library_provider.dart @@ -0,0 +1,66 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_files.dart'; + +/// Which collection the library screen shows. +enum LibraryTab { strategies, replays } + +final libraryTabProvider = StateProvider( + (ref) => LibraryTab.strategies, +); + +final replayFilesProvider = Provider((ref) => ReplayFiles()); + +/// One replay in the Replays tab: its file and what its header says. +class ReplayListing { + const ReplayListing({required this.file, this.probe, this.error}); + + final ReplayFile file; + final ReplayProbe? probe; + + /// Why the header could not be read; the file is still listed. + final ReplayDecodeException? error; + + bool get canOpen => probe != null && probe!.supported; +} + +final replayLibraryProvider = + AsyncNotifierProvider>( + ReplayLibrary.new, +); + +class ReplayLibrary extends AsyncNotifier> { + /// Headers already read, by file cache key: a refresh only probes new files. + final _probes = {}; + + @override + Future> build() => _load(); + + Future> _load() async { + final files = await ref.read(replayFilesProvider).list(); + final listings = []; + for (final file in files) { + listings.add(_probes[file.cacheKey] ??= await _probe(file)); + } + return listings; + } + + Future _probe(ReplayFile file) async { + try { + return ReplayListing(file: file, probe: await probeReplay(file.path)); + } on ReplayDecodeException catch (error) { + return ReplayListing(file: file, error: error); + } + } + + Future refresh() async { + state = await AsyncValue.guard(_load); + } + + /// Copies [path] into Icarus's replays folder and lists it. + Future import(String path) async { + final file = await ref.read(replayFilesProvider).import(path); + await refresh(); + return _probes[file.cacheKey] ?? await _probe(file); + } +} diff --git a/lib/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart new file mode 100644 index 000000000..fb2145e3e --- /dev/null +++ b/lib/replay/replay_ability_catalog.dart @@ -0,0 +1,525 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart'; + +/// Which Icarus ability a replay utility actor is, and how to place it. +/// +/// Null means the actor is not drawn: the projectile of an ability whose +/// lasting effect is its own actor, a cosmetic, or game bookkeeping. +ReplayAbilityEntry? replayAbilityFor(String classPath) => + _catalog[replayAssetName(classPath)]; + +/// The asset name of a class path, the part Riot names the blueprint by: +/// `/Game/Characters/Wraith/S0/Ability_4/Zone_Wraith_4_Smoke.Zone_Wraith_4_Smoke_C` +/// gives `Zone_Wraith_4_Smoke`. Folder layout moves between patches more +/// often than asset names do, so the catalog keys on this alone. +String replayAssetName(String classPath) => + classPath.split('/').last.split('.').first; + +/// Every asset name the catalog maps, for tests and tooling. +Iterable get replayAbilityAssetNames => _catalog.keys; + +/// How the replay viewer draws a utility while it is active, beyond the +/// Icarus ability [ReplayAbilityEntry.place] gives it. +enum ReplayUtilityKind { + /// An area centred on the utility: smokes, mollies, slows, reveal pulses. + area, + + /// A placed device or moving bot shown as its icon: traps, cameras, + /// Boom Bot. It follows the utility's path. + marker, + + /// Cover or a wire running from a start to an end (Phoenix's Blaze, + /// Viper's Toxic Screen, Cypher's Trapwire, Sage's Barrier Orb). + wall, + + /// A directed area that starts at the utility and runs along its yaw + /// (Breach's Fault Line, Fade's Nightfall). + rectangle, + + /// Utility in flight whose effect has no lasting actor of its own + /// (flashes, Paranoia, Showstopper's rocket). + projectile, + + /// Deadlock's Barrier Mesh: arms out from a centre. + barrierMesh, +} + +class ReplayAbilityEntry { + const ReplayAbilityEntry._( + this.agent, + this.abilityIndex, + this.kind, + this._placement, + ); + + final AgentType agent; + + /// Into `AgentData.agents[agent]!.abilities`, the index a saved ability + /// persists. It follows the ability's icon and shape in Icarus, which for + /// a few agents disagree with the display names in agents.dart. + final int abilityIndex; + final ReplayUtilityKind kind; + final _Placement _placement; + + AbilityInfo get ability => AgentData.agents[agent]!.abilities[abilityIndex]; + + /// The Icarus ability as it stands at [timeMs] (projectiles follow + /// utility.positionAt). + /// + /// [PlacedAbility.position] is saved as the widget's top-left at the + /// default marker size, so the shape's anchor ([storedAbilityAnchor]) is + /// subtracted from the projected game point, exactly as a drop in the + /// editor stores it. + PlacedAbility place({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required String id, + required bool isAlly, + }) { + final info = ability; + final shape = info.abilityData!; + final mapScale = Maps.mapScale[projection.map] ?? 1.0; + final geometry = _placement.resolve( + utility: utility, + projection: projection, + timeMs: timeMs, + shape: shape, + mapScale: mapScale, + ); + final anchorOffset = CoordinateSystem.instance.virtualOffsetToWorld( + storedAbilityAnchor(ability: shape, mapScale: mapScale), + ); + return PlacedAbility( + data: info, + position: geometry.anchor - anchorOffset, + id: id, + isAlly: isAlly, + rotation: geometry.rotation, + length: geometry.length, + armLengthsMeters: geometry.armLengthsMeters, + ); + } +} + +/// Where the ability's anchor lands, in canonical world units, and the +/// PlacedAbility geometry around it. +class _Geometry { + const _Geometry( + this.anchor, { + this.rotation = 0, + this.length = 0, + this.armLengthsMeters, + }); + + final Offset anchor; + final double rotation; + final double length; + final List? armLengthsMeters; +} + +sealed class _Placement { + const _Placement(); + + _Geometry resolve({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required Ability shape, + required double mapScale, + }); +} + +/// The utility is a point (a smoke, a molly, a trap, a bot) and the +/// ability's anchor, its centre or icon, sits where the utility is at the +/// moment. +class _AtPoint extends _Placement { + const _AtPoint({this.yawOffset}); + + /// Turns the ability to the utility's yaw plus this many degrees. Null + /// leaves it unrotated, for shapes whose rotation means nothing. + final double? yawOffset; + + @override + _Geometry resolve({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required Ability shape, + required double mapScale, + }) { + final at = utility.positionAt(timeMs); + final yaw = utility.yaw; + return _Geometry( + projection.toWorld(at.x, at.y), + rotation: yawOffset == null || yaw == null + ? 0 + : projection.rotationForYaw(yaw + yawOffset!), + ); + } +} + +/// A wall or directed area that starts at the utility and runs one way. +/// +/// It starts at the first shape point, else where the utility spawned, and +/// runs toward the last shape point, else along the utility's yaw. Icarus +/// anchors these squares at the caster, a handle's width (plus the +/// ability's gap) short of the shape's near edge, so the anchor is set back +/// by that much and the near edge lands on the start. A resizable square +/// also takes its length from start to end. +class _FromStart extends _Placement { + const _FromStart(); + + @override + _Geometry resolve({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required Ability shape, + required double mapScale, + }) { + final startPoint = + utility.points.isEmpty ? utility.position : utility.points.first; + final start = projection.toWorld(startPoint.x, startPoint.y); + final endPoint = utility.points.length >= 2 ? utility.points.last : null; + final end = + endPoint == null ? null : projection.toWorld(endPoint.x, endPoint.y); + + final double rotation; + if (end != null && end != start) { + rotation = _rotationToward(end - start); + } else if (utility.yaw != null) { + rotation = projection.rotationForYaw(utility.yaw!); + } else { + rotation = 0; + } + + final coordinates = CoordinateSystem.instance; + var nearEdge = 0.0; + var length = 0.0; + if (shape is SquareAbility) { + final anchor = storedAbilityAnchor(ability: shape, mapScale: mapScale); + nearEdge = coordinates.virtualLengthToWorld( + anchor.dy - shape.height * mapScale, + ); + if (shape is ResizableSquareAbility && end != null) { + length = + (end - start).distance / coordinates.virtualLengthToWorld(mapScale); + } + } + return _Geometry( + start - _unitForRotation(rotation) * nearEdge, + rotation: rotation, + length: length, + ); + } +} + +/// Deadlock's Barrier Mesh: centred on the utility, with arms reaching to +/// the shape points (each deployer's replicated destination). +/// +/// Icarus draws an arm of `n` at `n * AgentData.inGameMetersDiameter`, twice +/// the scale of its other ranges, so arms are converted through that same +/// helper and each end lands on its game destination. The game lays the +/// arms on the world diagonals; with no points, the default arms are turned +/// to match. +class _BarrierMesh extends _Placement { + const _BarrierMesh(); + + // Michel Giehl's ValorantReplayParser (MIT) has no Barrier Mesh + // descriptor; the arms come from GameObject_CableJam_CableDeployer_ + // Precomputed's MulticastInitialize.Destination, observed in 13.00 replays. + + @override + _Geometry resolve({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required Ability shape, + required double mapScale, + }) { + final centre = projection.toWorld(utility.position.x, utility.position.y); + double slotOffset(DeadlockBarrierMeshArm arm) => arm.angle + math.pi / 2; + + if (utility.points.isEmpty) { + return _Geometry( + centre, + rotation: projection.rotationForYaw(45) - + slotOffset(DeadlockBarrierMeshArm.topRight), + ); + } + + final arms = [ + for (final point in utility.points) + projection.toWorld(point.x, point.y) - centre, + ]; + final rotation = _rotationToward(arms.first) - + slotOffset(DeadlockBarrierMeshArm.topRight); + final worldPerArmMeter = CoordinateSystem.instance.virtualLengthToWorld( + deadlockBarrierMeshArmLengthVirtual(1, mapScale), + ); + // A blocked arm has no deployer; it shows at the shortest Icarus allows. + final lengths = List.filled( + DeadlockBarrierMeshArm.values.length, + DeadlockBarrierMeshAbility.minArmLengthMeters, + ); + for (final arm in arms) { + final relative = _rotationToward(arm) - rotation; + final slot = DeadlockBarrierMeshArm.values.reduce( + (best, next) => _angleBetween(relative, slotOffset(next)) < + _angleBetween(relative, slotOffset(best)) + ? next + : best, + ); + lengths[slot.index] = arm.distance / worldPerArmMeter; + } + return _Geometry(centre, rotation: rotation, armLengthsMeters: lengths); + } +} + +/// Icarus rotation for a world direction: 0 points up, clockwise positive. +double _rotationToward(Offset direction) => + math.atan2(direction.dy, direction.dx) + math.pi / 2; + +Offset _unitForRotation(double rotation) => + Offset(math.sin(rotation), -math.cos(rotation)); + +double _angleBetween(double a, double b) { + final difference = (a - b) % (2 * math.pi); + return math.min(difference, 2 * math.pi - difference); +} + +const _point = _AtPoint(); +const _facing = _AtPoint(yawOffset: 0); +const _fromStart = _FromStart(); + +ReplayAbilityEntry _area(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.area, _point); +ReplayAbilityEntry _marker(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.marker, _point); +ReplayAbilityEntry _projectile(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.projectile, _point); +ReplayAbilityEntry _wall(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.wall, _fromStart); +ReplayAbilityEntry _rectangle(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.rectangle, _fromStart); + +// Keyed by asset name. Agents go by Riot's codenames in class paths (paired +// through valorant-api.com's developerName). The slot folder in a path +// (Ability_4, _Q, _E, _X) is not Icarus's order and differs per agent: +// Cypher's Trapwire lives in Ability_E, Sage's Barrier Orb in Ability_E. +// Agents marked "asset index" were absent from the replays we have; their +// entries come from the 13.05 package index and Michel Giehl's +// ValorantReplayParser (MIT) descriptors, and are unverified against data. +final Map _catalog = { + // Astra (Rift), asset index. Astra Star (index 4) has no actor we know of. + 'GameObject_Rift_4_BlackHole': _area(AgentType.astra, 0), + 'GameObject_Rift_Q_FlashBurst': _area(AgentType.astra, 1), + 'GameObject_Rift_E_SmokeZone': _area(AgentType.astra, 2), + 'GameObject_Rift_E_SmokeZone_Fake': _area(AgentType.astra, 2), + 'GameObject_Rift_X_GlobalWall': const ReplayAbilityEntry._( + AgentType.astra, 3, ReplayUtilityKind.wall, _facing), + + // Breach. Rolling Thunder from the asset index. + 'GameObject_Breach_4_FusionBlast': _rectangle(AgentType.breach, 0), + 'Projectile_Breach_Q_ThroughWalls_Flash': _projectile(AgentType.breach, 1), + 'GameObject_Breach_E_SweetSpotFissure': _rectangle(AgentType.breach, 2), + 'GameObject_Breach_X_Shockwave': _rectangle(AgentType.breach, 3), + + // Brimstone (Sarge). + 'GameObject_Sarge_E_SpeedStim': _area(AgentType.brimstone, 0), + 'Patch_Sarge_Q_Molotov_Production': _area(AgentType.brimstone, 1), + 'GameObject_Sarge_4_Smoke_ProductionNEW': _area(AgentType.brimstone, 2), + 'GameObject_Sarge_X_OrbitalStrike_Production': _area(AgentType.brimstone, 3), + + // Chamber (Deadeye). Headhunter and Tour De Force are guns. + 'GameObject_Deadeye_E_Trap': _area(AgentType.chamber, 0), + 'Patch_Deadeye_E_Slow_Large': _area(AgentType.chamber, 0), + 'GameObject_Deadeye_E_Teleporter_Tether': _area(AgentType.chamber, 2), + + // Clove (Smonk). Pick-me-up and Not Dead Yet have no actor. + 'GameObject_Smonk_Q_DecayExplosion': _area(AgentType.clove, 1), + 'GameObject_Smonk_NewSmoke': _area(AgentType.clove, 2), + 'GameObject_Smonk_NewSmoke_PDS': _area(AgentType.clove, 2), + + // Cypher (Gumshoe). The Trapwire actor sits on its first anchor, its yaw + // toward the second; the wire's end is the _SecondWire actor's position, + // which the decoder carries as the wire's shape points. + 'GameObject_Gumshoe_E_TripWire': _wall(AgentType.cypher, 0), + 'Projectile_Gumshoe_4_CageTrap': _marker(AgentType.cypher, 1), + 'Zone_Gumshoe_4_Cage': _area(AgentType.cypher, 1), + 'Pawn_Gumshoe_Q_PossessableCamera': _marker(AgentType.cypher, 2), + 'GameObject_RemovableObject_GumshoeTrackingDart': + _marker(AgentType.cypher, 2), + + // Deadlock (Cable). Icarus index 0 draws GravNet and index 2 the Barrier + // Mesh (by icon and shape), though agents.dart names them the other way. + 'Patch_NetToss': _area(AgentType.deadlock, 0), + 'GameObject_StealthingTrap_SoundSensor': _rectangle(AgentType.deadlock, 1), + 'GameObject_SoundSensor_SweetSpotFissure': _rectangle(AgentType.deadlock, 1), + 'GameObject_CableJamRoot': const ReplayAbilityEntry._( + AgentType.deadlock, 2, ReplayUtilityKind.barrierMesh, _BarrierMesh()), + 'Actor_FishingHook': _projectile(AgentType.deadlock, 3), + + // Fade (BountyHunter). + 'Pawn_BountyHunter_4_WolfHound': _marker(AgentType.fade, 0), + 'GameObject_Q_BountyHunter_Tether_SphereExpansion': _area(AgentType.fade, 1), + 'Projectile_E_BountyHunter_Divebomb': _projectile(AgentType.fade, 2), + 'GameObject_BountyHunter_E_LoSReveal_Source_Reactivate': + _area(AgentType.fade, 2), + 'GameObject_BountyHunter_X_WaveForm': _rectangle(AgentType.fade, 3), + + // Gekko (AggroBot). Reclaim globules are left out: placing one as its + // ability would draw Mosh Pit's circle where no Mosh Pit is. + 'Patch_Aggrobot_C_ExplodeyPatch': _area(AgentType.gekko, 0), + 'Pawn_Aggrobot_SeekerNade': _marker(AgentType.gekko, 1), + 'Projectile_E_Aggrobot_DiscTurret_PowerWave': _projectile(AgentType.gekko, 2), + 'Pawn_Aggrobot_RollyPolly': _marker(AgentType.gekko, 3), + + // Harbor (Mage), asset index. + 'GameObject_Mage_4_SplashGrenade': _area(AgentType.harbor, 0), + 'Projectile_Mage_Q_Wall': const ReplayAbilityEntry._( + AgentType.harbor, 1, ReplayUtilityKind.wall, _point), + 'GameObject_Mage_E_WorldSmoke': _area(AgentType.harbor, 2), + 'GameObject_Mage_X_TidalWave': _rectangle(AgentType.harbor, 3), + + // Iso (Sequoia). + 'GameObject_Sequoia_4_MovingCover': _rectangle(AgentType.iso, 0), + 'GameObject_Sequoia_4_MovingCover_Slower': _rectangle(AgentType.iso, 0), + 'Projectile_Sequoia_Q_FragileMissile': const ReplayAbilityEntry._( + AgentType.iso, 1, ReplayUtilityKind.projectile, _fromStart), + 'GameObject_Sequoia_E_Orb': _marker(AgentType.iso, 2), + 'GameObject_Sequoia_X_LineCapture': _rectangle(AgentType.iso, 3), + + // Jett (Wushu). Updraft, Tailwind and Blade Storm have no lasting actor. + 'GameObject_Wushu_4_SmokeZone': _area(AgentType.jett, 0), + + // KAY/O (Grenadier). FRAG/ment and NULL/cmd from the asset index. + 'Projectile_Grenadier_Q_SemtexBasic': _projectile(AgentType.kayo, 0), + 'Projectile_C_Grenadier_Flash': _projectile(AgentType.kayo, 1), + 'Projectile_C_Grenadier_Flash_Underhand': _projectile(AgentType.kayo, 1), + 'Projectile_Grenadier_E_SuppressionBlade': _projectile(AgentType.kayo, 2), + 'Gameobject_Grenadier_E_SuppressionPulse': _area(AgentType.kayo, 2), + 'Gameobject_Grenadier_X_UltPulse': _area(AgentType.kayo, 3), + + // Killjoy. + 'Projectile_Killjoy_4_RemoteBees_MultiDetonate': _area(AgentType.killjoy, 0), + 'GameObject_Killjoy_4_BeeSwarm_Damage': _area(AgentType.killjoy, 0), + 'Pawn_Killjoy_Q_StealthAlarmbot': _area(AgentType.killjoy, 1), + 'Pawn_Killjoy_E_Turret': _area(AgentType.killjoy, 2), + 'GameObject_Killjoy_X_Bomb': _area(AgentType.killjoy, 3), + + // Miks (Iris). Icarus index 2 draws Waveform's smoke and index 3 is + // Harmonize (by icon), though agents.dart names them the other way. + // Harmonize is a buff with no actor. + 'GameObject_Thumper_Concuss': _area(AgentType.miks, 0), + 'GameObject_Thumper_Heal': _area(AgentType.miks, 1), + 'GameObject_Iris_E_Smoke': _area(AgentType.miks, 2), + 'GameObject_Iris_X_SonicWave': const ReplayAbilityEntry._( + AgentType.miks, 4, ReplayUtilityKind.area, _facing), + + // Neon (Sprinter). High Gear and Overdrive have no actor. + 'GameObject_Sprinter_4_Tunnel': _wall(AgentType.neon, 0), + 'GameObject_Sprinter_Q_ElectricSphere': _area(AgentType.neon, 1), + + // Omen (Wraith). Shrouded Step has no actor. + 'Projectile_Wraith_Q_NearsightMissile': const ReplayAbilityEntry._( + AgentType.omen, 1, ReplayUtilityKind.projectile, _fromStart), + 'Zone_Wraith_4_Smoke': _area(AgentType.omen, 2), + 'Intention_Wraith_X_GlobalTeleport': _marker(AgentType.omen, 3), + + // Phoenix. Icarus index 1 is Curveball and index 2 Hot Hands (by icon and + // shape), though agents.dart names them the other way. + 'GameObject_Phoenix_Q_FlameWallManager_Production': + _wall(AgentType.pheonix, 0), + 'Projectile_Phoenix_E_FlareCurve_Synced': _projectile(AgentType.pheonix, 1), + 'Projectile_Phoenix_E_FlareCurve_Synced_Right': + _projectile(AgentType.pheonix, 1), + 'Patch_Phoenix_MolotovFire': _area(AgentType.pheonix, 2), + 'GameObject_Phoenix_X_ResTarget_Production': _marker(AgentType.pheonix, 3), + + // Raze (Clay). + 'Pawn_Clay_E_Boomba': _marker(AgentType.raze, 0), + 'Projectile_Clay_Q_Satchel_Arming': _marker(AgentType.raze, 1), + 'Projectile_Clay_4_Projectile_Primary': _projectile(AgentType.raze, 2), + 'Projectile_Clay_X_Rocket': _projectile(AgentType.raze, 3), + + // Reyna (Vampire). Devour and Dismiss spend soul orbs, which come from + // kills, not from an ability; Empress has no actor. + 'GameObject_Vampire_4_NearsightAOE_Source': _marker(AgentType.reyna, 0), + + // Sage (Thorne). The wall's actor is its centre and the wall runs across + // its yaw. Healing Orb and Resurrection have no actor. + 'GameObject_Thorne_E_Wall_Fortifying': const ReplayAbilityEntry._( + AgentType.sage, 0, ReplayUtilityKind.wall, _AtPoint(yawOffset: 90)), + 'Patch_Thorne_4_SlowField_Production': _area(AgentType.sage, 1), + + // Skye (Guide). + 'GameObject_Guide_4_Heal_AOE': _area(AgentType.skye, 0), + 'Pawn_Guide_Q_PossessableScout': _marker(AgentType.skye, 1), + 'Projectile_Guide_E_HawkFlash': _projectile(AgentType.skye, 2), + 'GameObject_Guide_E_HawkFlash_FlashSource': _marker(AgentType.skye, 2), + 'Pawn_Guide_X_Pack': _marker(AgentType.skye, 3), + + // Sova (Hunter). Hunter's Fury has no actor. + 'Pawn_Hunter_E_Drone': _marker(AgentType.sova, 0), + 'GameObject_Hunter_E_Drone_RevealDart': _marker(AgentType.sova, 0), + 'GameObject_Hunter_4_ExplosiveBolt_Explosion': _area(AgentType.sova, 1), + 'GameObject_Hunter_Q_SonarBolt': _area(AgentType.sova, 2), + + // Tejo (Cashew). Explosions from the asset index. + 'Pawn_Cashew_4_Spider_LockOn': _marker(AgentType.tejo, 0), + 'GameObject_Cashew_4_SonarPing': _area(AgentType.tejo, 0), + 'Projectile_Cashew_Q_ShellShockGrenade': _projectile(AgentType.tejo, 1), + 'Projectile_Cashew_Q_ShellShockGrenade_Bounce': + _projectile(AgentType.tejo, 1), + 'GameObject_Cashew_Q_ShellShockGrenade': _area(AgentType.tejo, 1), + 'GameObject_Cashew_E_MapMissileMarker': _area(AgentType.tejo, 2), + 'GameObject_Cashew_E_MapMissileMarker_SecondRocket': _area(AgentType.tejo, 2), + 'GameObject_Cashew_E_Explosion': _area(AgentType.tejo, 2), + 'GameObject_Cashew_X_SegmentManager': _rectangle(AgentType.tejo, 3), + + // Veto (Pine), asset index. Evolution is a self-buff with no actor. + 'GameObject_Pine_4_UsableTeleport': _area(AgentType.veto, 0), + 'GameObject_Pine_Q_SeizeTrap': _area(AgentType.veto, 1), + 'GameObject_Pine_Q_Tether_SphereExpansion': _area(AgentType.veto, 1), + 'Pawn_Pine_E_RadEater': _area(AgentType.veto, 2), + + // Viper (Pandemic), asset index. Toxic Screen's points come from its + // manager's MulticastAddSmokeScreenPoint (Giehl's SmokeScreenManager). + 'Patch_Pandemic_AcidMolotov_NewMolotov': _area(AgentType.viper, 0), + 'GameObject_Pandemic_4_SmokeZone': _area(AgentType.viper, 1), + 'GameObject_Pandemic_E_SmokeScreenManager': _wall(AgentType.viper, 2), + 'Patch_Pandemic_X_Circular': _area(AgentType.viper, 3), + + // Vyse (Nox). Icarus index 0 draws Shear and index 1 Razorvine (by icon + // and shape), though agents.dart names them the other way. Shear's wall + // and its armed trap both replicate a start and an end. + 'GameObject_Nox_Wall': _wall(AgentType.vyse, 0), + 'GameObject_Nox_WallTrap': _wall(AgentType.vyse, 0), + 'GameObject_Nox_BarbedWire': _area(AgentType.vyse, 1), + 'Patch_Nox_BarbedWire': _area(AgentType.vyse, 1), + 'GameObject_Nox_StealthingTrap_Flash_2': _marker(AgentType.vyse, 2), + 'Gameobject_Nox_DisarmPulse': _area(AgentType.vyse, 3), + + // Waylay (Terra), asset index. Lightspeed has no actor. + 'GameObject_Terra_C_TimeSlowGrenade_Explosion': _area(AgentType.waylay, 0), + 'GameObject_Terra_E_RewindTime_RewindTarget': _marker(AgentType.waylay, 2), + 'GameObject_Terra_X_DelayedBeam_Beam': _rectangle(AgentType.waylay, 3), + + // Yoru (Stealth). Dimensional Drift has no actor. + 'Pawn_Stealth_4_Decoy_V2': _marker(AgentType.yoru, 0), + 'Projectile_Stealth_Q_BounceFlash': _projectile(AgentType.yoru, 1), + 'Pawn_Stealth_E_TeleporterMoving_FakeTP': _marker(AgentType.yoru, 2), + 'Pawn_Stealth_E_TeleporterStationary_FakeTP': _marker(AgentType.yoru, 2), +}; diff --git a/lib/replay/replay_agents.dart b/lib/replay/replay_agents.dart new file mode 100644 index 000000000..555c67964 --- /dev/null +++ b/lib/replay/replay_agents.dart @@ -0,0 +1,39 @@ +import 'package:icarus/const/agents.dart'; + +/// Icarus agents by the valorant-api.com agent UUID a replay names them by. +/// A new agent is missing here until Icarus ships it; callers show those +/// players without an agent icon rather than guessing. +const Map replayAgentsById = { + '41fb69c1-4189-7b37-f117-bcaf1e96f1bf': AgentType.astra, + '5f8d3a7f-467b-97f3-062c-13acf203c006': AgentType.breach, + '9f0d8ba9-4140-b941-57d3-a7ad57c6b417': AgentType.brimstone, + '22697a3d-45bf-8dd7-4fec-84a9e28c69d7': AgentType.chamber, + '1dbf2edd-4729-0984-3115-daa5eed44993': AgentType.clove, + '117ed9e3-49f3-6512-3ccf-0cada7e3823b': AgentType.cypher, + 'cc8b64c8-4b25-4ff9-6e7f-37b4da43d235': AgentType.deadlock, + 'dade69b4-4f5a-8528-247b-219e5a1facd6': AgentType.fade, + 'e370fa57-4757-3604-3648-499e1f642d3f': AgentType.gekko, + '95b78ed7-4637-86d9-7e41-71ba8c293152': AgentType.harbor, + '0e38b510-41a8-5780-5e8f-568b2a4f2d6c': AgentType.iso, + 'add6443a-41bd-e414-f6ad-e58d267f4e95': AgentType.jett, + '601dbbe7-43ce-be57-2a40-4abd24953621': AgentType.kayo, + '1e58de9c-4950-5125-93e9-a0aee9f98746': AgentType.killjoy, + '7c8a4701-4de6-9355-b254-e09bc2a34b72': AgentType.miks, + 'bb2a4828-46eb-8cd1-e765-15848195d751': AgentType.neon, + '8e253930-4c05-31dd-1b6c-968525494517': AgentType.omen, + 'eb93336a-449b-9c1b-0a54-a891f7921d69': AgentType.pheonix, + 'f94c3b30-42be-e959-889c-5aa313dba261': AgentType.raze, + 'a3bfb853-43b2-7238-a4f1-ad90e9e46bcc': AgentType.reyna, + '569fdd95-4d10-43ab-ca70-79becc718b46': AgentType.sage, + '6f2a04ca-43e0-be17-7f36-b3908627744d': AgentType.skye, + '320b2a48-4d9b-a075-30f1-1f93a9b638fa': AgentType.sova, + 'b444168c-4e35-8076-db47-ef9bf368f384': AgentType.tejo, + '92eeef5d-43b5-1d4a-8d03-b3927a09034b': AgentType.veto, + '707eab51-4836-f488-046a-cda6bf494859': AgentType.viper, + 'efba5359-4016-a1e5-7626-b1ae76895940': AgentType.vyse, + 'df1cb487-4902-002e-5c17-d28e83e78588': AgentType.waylay, + '7f94d92c-4234-0a36-9646-3a87eb8b5c89': AgentType.yoru, +}; + +AgentType? replayAgentType(String agentId) => + replayAgentsById[agentId.toLowerCase()]; diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart new file mode 100644 index 000000000..70ffb149c --- /dev/null +++ b/lib/replay/replay_capture.dart @@ -0,0 +1,70 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:uuid/uuid.dart'; + +/// Saves replay moments as pages of one strategy. The first capture creates +/// the strategy; later ones append to it, so a replay session builds one +/// strategy page by page. +/// +/// Players and utility keep their replay ids on every page, so the editor's +/// page transitions move each player from one captured moment to the next. +class ReplayCapture { + ReplayCapture({required this.ref, required this.map, required this.name}); + + final WidgetRef ref; + final MapValue map; + + /// The strategy's name when the first capture creates it. + final String name; + + String? _strategyId; + + /// The strategy captures go to, once there is one. + StrategyData? get strategy { + final id = _strategyId; + if (id == null) return null; + return Hive.box(HiveBoxNames.strategiesBox).get(id); + } + + /// Saves [frame] as a page named [pageName] and returns the strategy. + Future capture(ReplayFrame frame, + {required String pageName}) async { + final box = Hive.box(HiveBoxNames.strategiesBox); + var target = strategy; + final replaceBlankPage = target == null; + target ??= await ref + .read(strategyProvider.notifier) + .createNewStrategy(map: map, name: name); + _strategyId = target.id; + + final pages = [...target.pages] + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final page = StrategyPage( + id: const Uuid().v4(), + name: pageName, + isAutoNamed: false, + drawingData: const [], + agentData: frame.agents, + abilityData: frame.abilities, + textData: const [], + imageData: const [], + utilityData: frame.utilities, + sortIndex: replaceBlankPage ? 0 : pages.length, + isAttack: frame.isAttack, + settings: pages.first.settings, + ); + final updated = target.copyWith( + // A new strategy comes with one blank page; the first capture takes + // its place rather than following it. + pages: replaceBlankPage ? [page] : [...pages, page], + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + return updated; + } +} diff --git a/lib/replay/replay_decoder.dart b/lib/replay/replay_decoder.dart new file mode 100644 index 000000000..03ce0476a --- /dev/null +++ b/lib/replay/replay_decoder.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +export 'replay_decoder_stub.dart' if (dart.library.io) 'replay_decoder_io.dart'; + +/// Why a replay could not be read. [code] is one of the error codes in +/// `docs/replay-format.md`. +class ReplayDecodeException implements Exception { + const ReplayDecodeException(this.code, this.message, {this.build}); + + final String code; + final String message; + + /// The replay's game build, when the failure is about the build. + final String? build; + + bool get isUnsupportedBuild => code == 'unsupportedBuild'; + bool get isCancelled => code == 'cancelled'; + + factory ReplayDecodeException.fromJson(Map json) => + ReplayDecodeException( + json['code'] as String? ?? 'corrupt', + json['message'] as String? ?? 'The replay could not be read.', + build: json['build'] as String?, + ); + + @override + String toString() => 'ReplayDecodeException($code): $message'; +} + +/// What a replay's header says, read without decoding the match. +class ReplayProbe { + const ReplayProbe({ + required this.matchId, + required this.mapPath, + required this.build, + required this.durationMs, + required this.supported, + required this.decoderVersion, + required this.agentIds, + this.recordedAt, + }); + + final String matchId; + final String mapPath; + final String build; + final int durationMs; + final DateTime? recordedAt; + + /// Whether this build's payload transform is known, so a decode can work. + final bool supported; + + /// Changes whenever the decoder's output could; decoded caches key by it. + final String decoderVersion; + + /// Agent UUIDs of the ten players, in header order. + final List agentIds; + + factory ReplayProbe.fromJson(Map json) => ReplayProbe( + matchId: json['id'] as String, + mapPath: json['mapPath'] as String, + build: json['build'] as String, + durationMs: json['durationMs'] as int, + recordedAt: DateTime.tryParse(json['recordedAt'] as String? ?? ''), + supported: json['supported'] as bool? ?? false, + decoderVersion: json['decoderVersion'] as String, + agentIds: [ + for (final player in (json['players'] as List? ?? const [])) + ((player as Map)['agentId'] as String).toLowerCase(), + ], + ); +} + +/// A decode in flight. [progress] can be read from any frame; [cancel] +/// stops the decoder at its next chunk boundary and [result] then fails +/// with a `cancelled` [ReplayDecodeException]. +abstract class ReplayDecodeJob { + Future get result; + + /// 0 to 1. + double get progress; + + void cancel(); +} diff --git a/lib/replay/replay_decoder_io.dart b/lib/replay/replay_decoder_io.dart new file mode 100644 index 000000000..43cd81845 --- /dev/null +++ b/lib/replay/replay_decoder_io.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'replay_decoder.dart'; + +const bool replayDecoderAvailable = true; + +/// Where the bundled `native/replay` library sits next to the executable. +/// `ICARUS_REPLAY_LIBRARY` overrides it for tests and tools. +String replayNativeLibraryPath() { + final override = Platform.environment['ICARUS_REPLAY_LIBRARY']; + if (override != null && override.isNotEmpty) return override; + final executable = File(Platform.resolvedExecutable).parent; + if (Platform.isWindows) return '${executable.path}/icarus_replay.dll'; + if (Platform.isLinux) return '${executable.path}/lib/libicarus_replay.so'; + if (Platform.isMacOS) { + return '${executable.parent.path}/Frameworks/libicarus_replay.dylib'; + } + throw UnsupportedError('Replays require a desktop build.'); +} + +final class _Buffer extends Struct { + external Pointer ptr; + + @Size() + external int len; + + @Uint32() + external int isError; +} + +typedef _ProbeC = _Buffer Function(Pointer); +typedef _DecodeC = _Buffer Function( + Pointer, Pointer, Pointer); +typedef _DecodeD = _Buffer Function( + Pointer, Pointer, Pointer); +typedef _FreeC = Void Function(_Buffer); +typedef _FreeD = void Function(_Buffer); + +class _Library { + _Library(DynamicLibrary library) + : probe = library.lookupFunction<_ProbeC, _ProbeC>('icarus_replay_probe'), + decode = library.lookupFunction<_DecodeC, _DecodeD>( + 'icarus_replay_decode', + ), + free = library.lookupFunction<_FreeC, _FreeD>('icarus_replay_free'); + + final _Buffer Function(Pointer) probe; + final _DecodeD decode; + final _FreeD free; + + static _Library? _instance; + + /// Opened once per isolate. + static _Library get instance { + final existing = _instance; + if (existing != null) return existing; + final DynamicLibrary library; + try { + library = DynamicLibrary.open(replayNativeLibraryPath()); + } on ArgumentError catch (error) { + throw ReplayDecodeException( + 'io', + 'The replay decoder is missing from this install ($error).', + ); + } + return _instance = _Library(library); + } + + /// Copies the buffer out, frees it, and throws when it holds an error. + Uint8List take(_Buffer buffer) { + try { + final bytes = Uint8List.fromList(buffer.ptr.asTypedList(buffer.len)); + if (buffer.isError != 0) { + throw ReplayDecodeException.fromJson( + jsonDecode(utf8.decode(bytes)) as Map, + ); + } + return bytes; + } finally { + free(buffer); + } + } +} + +Future probeReplay(String path) => Isolate.run(() { + final library = _Library.instance; + final nativePath = path.toNativeUtf8(); + try { + final bytes = library.take(library.probe(nativePath)); + return ReplayProbe.fromJson( + jsonDecode(utf8.decode(bytes)) as Map, + ); + } finally { + malloc.free(nativePath); + } + }); + +ReplayDecodeJob decodeReplay(String path) => _IoDecodeJob(path); + +class _IoDecodeJob implements ReplayDecodeJob { + _IoDecodeJob(String path) { + // Both cells are written across isolates, so they live in native memory + // and are freed only after the decoding isolate has finished with them. + final progressAddress = _progress.address; + final cancelAddress = _cancel.address; + result = Isolate.run(() { + final library = _Library.instance; + final nativePath = path.toNativeUtf8(); + try { + return library.take( + library.decode( + nativePath, + Pointer.fromAddress(progressAddress), + Pointer.fromAddress(cancelAddress), + ), + ); + } finally { + malloc.free(nativePath); + } + }).whenComplete(() { + calloc.free(_progress); + calloc.free(_cancel); + _done = true; + }); + } + + final Pointer _progress = calloc(); + final Pointer _cancel = calloc(); + bool _done = false; + + @override + late final Future result; + + @override + double get progress => _done ? 1 : _progress.value / 10000; + + @override + void cancel() { + if (!_done) _cancel.value = 1; + } +} diff --git a/lib/replay/replay_decoder_stub.dart b/lib/replay/replay_decoder_stub.dart new file mode 100644 index 000000000..c8244a965 --- /dev/null +++ b/lib/replay/replay_decoder_stub.dart @@ -0,0 +1,15 @@ +import 'replay_decoder.dart'; + +const bool replayDecoderAvailable = false; + +Future probeReplay(String path) => Future.error( + const ReplayDecodeException( + 'io', + 'Replays can only be opened in the desktop app.', + ), + ); + +ReplayDecodeJob decodeReplay(String path) => throw const ReplayDecodeException( + 'io', + 'Replays can only be opened in the desktop app.', + ); diff --git a/lib/replay/replay_document.dart b/lib/replay/replay_document.dart new file mode 100644 index 000000000..af08012dc --- /dev/null +++ b/lib/replay/replay_document.dart @@ -0,0 +1,633 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:typed_data'; + +/// One decoded Valorant replay, read from the `.icrp` buffer that +/// `native/replay` writes. Facts only, in game units: no Icarus types appear +/// here. The format is specified in `docs/replay-format.md`. +class ReplayDocument { + ReplayDocument._({ + required this.match, + required this.players, + required this.rounds, + required this.kills, + required this.utility, + required this.casts, + required this.vitals, + required this.movement, + required this.quality, + }); + + static const formatVersion = 1; + static const _magic = [0x49, 0x43, 0x52, 0x50]; // "ICRP" + + final ReplayMatchInfo match; + final List players; + final List rounds; + final List kills; + final List utility; + final List casts; + + /// Health and armor per player, one row per change. + final Map vitals; + final Map movement; + final ReplayQuality quality; + + /// Reads a whole `.icrp` buffer. Throws [FormatException] when the bytes + /// are not a decoded replay of [formatVersion]. + factory ReplayDocument.fromBytes(Uint8List bytes) { + if (bytes.length < 12) { + throw const FormatException('Decoded replay is truncated.'); + } + for (var i = 0; i < _magic.length; i++) { + if (bytes[i] != _magic[i]) { + throw const FormatException('Not a decoded replay.'); + } + } + final header = ByteData.sublistView(bytes, 0, 12); + final version = header.getUint32(4, Endian.little); + if (version != formatVersion) { + throw FormatException( + 'Decoded replay format $version is not ' + 'supported (expected $formatVersion).', + ); + } + final jsonLength = header.getUint32(8, Endian.little); + final jsonEnd = 12 + jsonLength; + if (jsonEnd > bytes.length) { + throw const FormatException('Decoded replay is truncated.'); + } + final json = + jsonDecode(utf8.decode(Uint8List.sublistView(bytes, 12, jsonEnd))) + as Map; + final blobStart = (jsonEnd + 7) & ~7; + final blob = blobStart <= bytes.length + ? ByteData.sublistView(bytes, blobStart) + : ByteData(0); + return ReplayDocument._fromJson(json, blob); + } + + factory ReplayDocument._fromJson(Map json, ByteData blob) { + List> list(String key) => + (json[key] as List? ?? const []).cast>(); + + final movementJson = + (json['movement'] as Map? ?? const {}).cast(); + final vitalsJson = + (json['vitals'] as Map? ?? const {}).cast(); + + return ReplayDocument._( + match: ReplayMatchInfo.fromJson(json['match'] as Map), + players: [for (final p in list('players')) ReplayPlayer.fromJson(p)], + rounds: [for (final r in list('rounds')) ReplayRound.fromJson(r)], + kills: [for (final k in list('kills')) ReplayKill.fromJson(k)] + ..sort((a, b) => a.timeMs.compareTo(b.timeMs)), + utility: [for (final u in list('utility')) ReplayUtility.fromJson(u)] + ..sort((a, b) => a.spawnMs.compareTo(b.spawnMs)), + casts: [for (final c in list('casts')) ReplayCast.fromJson(c)] + ..sort((a, b) => a.timeMs.compareTo(b.timeMs)), + vitals: { + for (final entry in vitalsJson.entries) + entry.key: ReplayVitalsTrack.fromJson(entry.value as List), + }, + movement: { + for (final entry in movementJson.entries) + entry.key: ReplayMovementTrack.fromBlob( + blob, + offset: (entry.value as Map)['offset'] as int, + count: (entry.value as Map)['count'] as int, + ), + }, + quality: ReplayQuality.fromJson( + json['quality'] as Map? ?? const {}, + ), + ); + } + + ReplayPlayer? playerBySubject(String? subject) { + if (subject == null) return null; + for (final player in players) { + if (player.subject == subject) return player; + } + return null; + } + + /// The round whose span holds [timeMs]; before the first round, the first. + ReplayRound? roundAt(int timeMs) { + ReplayRound? current; + for (final round in rounds) { + if (round.startMs > timeMs) break; + current = round; + } + return current ?? (rounds.isEmpty ? null : rounds.first); + } +} + +/// Valorant's two teams as replicated. +enum ReplayTeam { + red, + blue; + + ReplayTeam get other => this == red ? blue : red; + + static ReplayTeam? tryParse(Object? value) => switch (value) { + 'Red' => red, + 'Blue' => blue, + _ => null, + }; +} + +class ReplayMatchInfo { + const ReplayMatchInfo({ + required this.id, + required this.mapPath, + required this.build, + required this.durationMs, + this.recordedAt, + this.gameMode, + }); + + final String id; + final String mapPath; + final String build; + final int durationMs; + final DateTime? recordedAt; + final String? gameMode; + + factory ReplayMatchInfo.fromJson(Map json) => + ReplayMatchInfo( + id: json['id'] as String, + mapPath: json['mapPath'] as String, + build: json['build'] as String, + durationMs: json['durationMs'] as int, + recordedAt: DateTime.tryParse(json['recordedAt'] as String? ?? ''), + gameMode: json['gameMode'] as String?, + ); +} + +class ReplayPlayer { + const ReplayPlayer({ + required this.subject, + required this.agentId, + required this.team, + this.name, + }); + + final String subject; + + /// valorant-api.com agent UUID. + final String agentId; + final ReplayTeam team; + final String? name; + + factory ReplayPlayer.fromJson(Map json) => ReplayPlayer( + subject: json['subject'] as String, + agentId: (json['agentId'] as String).toLowerCase(), + team: ReplayTeam.tryParse(json['team']) ?? + (throw FormatException('Player ${json['subject']} has no team.')), + name: json['name'] as String?, + ); +} + +enum ReplayRoundEnd { elimination, detonated, defused, time, surrender } + +class ReplayRound { + const ReplayRound({ + required this.index, + required this.startMs, + required this.endMs, + required this.attackingTeam, + this.combatStartMs, + this.winningTeam, + this.endReason, + this.plant, + this.defuse, + this.detonateMs, + this.economy = const [], + }); + + final int index; + final int startMs; + final int? combatStartMs; + final int endMs; + final ReplayTeam attackingTeam; + final ReplayTeam? winningTeam; + final ReplayRoundEnd? endReason; + final ReplaySpikePlant? plant; + final ReplaySpikeDefuse? defuse; + final int? detonateMs; + final List economy; + + /// When the round's play starts: barriers down if known, else round start. + int get playStartMs => combatStartMs ?? startMs; + + bool contains(int timeMs) => timeMs >= startMs && timeMs < endMs; + + ReplayEconomy? economyFor(String subject) { + for (final row in economy) { + if (row.subject == subject) return row; + } + return null; + } + + factory ReplayRound.fromJson(Map json) => ReplayRound( + index: json['index'] as int, + startMs: json['startMs'] as int, + combatStartMs: json['combatStartMs'] as int?, + endMs: json['endMs'] as int, + attackingTeam: ReplayTeam.tryParse(json['attackingTeam']) ?? + (throw FormatException( + 'Round ${json['index']} has no attacking team.', + )), + winningTeam: ReplayTeam.tryParse(json['winningTeam']), + endReason: ReplayRoundEnd.values + .where((value) => value.name == json['endReason']) + .firstOrNull, + plant: json['plant'] == null + ? null + : ReplaySpikePlant.fromJson(json['plant'] as Map), + defuse: json['defuse'] == null + ? null + : ReplaySpikeDefuse.fromJson( + json['defuse'] as Map), + detonateMs: json['detonateMs'] as int?, + economy: [ + for (final row in (json['economy'] as List? ?? const [])) + ReplayEconomy.fromJson(row as Map), + ], + ); +} + +class ReplaySpikePlant { + const ReplaySpikePlant({ + required this.timeMs, + required this.position, + this.site, + this.subject, + }); + + final int timeMs; + final ReplayVector position; + final String? site; + final String? subject; + + factory ReplaySpikePlant.fromJson(Map json) => + ReplaySpikePlant( + timeMs: json['timeMs'] as int, + position: ReplayVector.fromJson(json['position'] as List), + site: json['site'] as String?, + subject: json['subject'] as String?, + ); +} + +class ReplaySpikeDefuse { + const ReplaySpikeDefuse({required this.timeMs, this.subject}); + + final int timeMs; + final String? subject; + + factory ReplaySpikeDefuse.fromJson(Map json) => + ReplaySpikeDefuse( + timeMs: json['timeMs'] as int, + subject: json['subject'] as String?, + ); +} + +class ReplayEconomy { + const ReplayEconomy({ + required this.subject, + required this.credits, + required this.loadoutValue, + this.weapon, + this.armor, + }); + + final String subject; + final int credits; + final int loadoutValue; + + /// Equippable class path of the best weapon carried into the round. + final String? weapon; + + /// Shield points bought: 25 or 50. + final int? armor; + + factory ReplayEconomy.fromJson(Map json) => ReplayEconomy( + subject: json['subject'] as String, + credits: json['credits'] as int, + loadoutValue: json['loadoutValue'] as int, + weapon: json['weapon'] as String?, + armor: json['armor'] as int?, + ); +} + +class ReplayKill { + const ReplayKill({ + required this.timeMs, + required this.victim, + this.killer, + this.assists = const [], + this.damageSource, + }); + + final int timeMs; + final String victim; + final String? killer; + final List assists; + final String? damageSource; + + factory ReplayKill.fromJson(Map json) => ReplayKill( + timeMs: json['timeMs'] as int, + victim: json['victim'] as String, + killer: json['killer'] as String?, + assists: (json['assists'] as List? ?? const []).cast(), + damageSource: json['damageSource'] as String?, + ); +} + +class ReplayUtility { + const ReplayUtility({ + required this.id, + required this.classPath, + required this.spawnMs, + required this.position, + this.owner, + this.endMs, + this.yaw, + this.path = const [], + this.points = const [], + }); + + final int id; + final String classPath; + final String? owner; + final int spawnMs; + + /// When the actor closed. Null means it lasted to the end of the replay. + final int? endMs; + final ReplayVector position; + final double? yaw; + + /// Later replicated positions, for projectiles and moving utility. + final List path; + + /// Shape points, for walls and drawn paths. + final List points; + + bool isActiveAt(int timeMs) => + timeMs >= spawnMs && (endMs == null || timeMs < endMs!); + + /// Where the utility is at [timeMs]: the last path point reached, else + /// where it spawned. + ReplayVector positionAt(int timeMs) { + var current = position; + for (final point in path) { + if (point.timeMs > timeMs) break; + current = point.position; + } + return current; + } + + factory ReplayUtility.fromJson(Map json) => ReplayUtility( + id: json['id'] as int, + classPath: json['classPath'] as String, + owner: json['owner'] as String?, + spawnMs: json['spawnMs'] as int, + endMs: json['endMs'] as int?, + position: ReplayVector.fromJson(json['position'] as List), + yaw: (json['yaw'] as num?)?.toDouble(), + path: [ + for (final row in (json['path'] as List? ?? const [])) + ReplayPathPoint.fromJson(row as List), + ], + points: [ + for (final row in (json['points'] as List? ?? const [])) + ReplayVector.fromJson(row as List), + ], + ); +} + +class ReplayCast { + const ReplayCast({ + required this.timeMs, + required this.subject, + required this.classPath, + this.position, + }); + + final int timeMs; + final String subject; + final String classPath; + final ReplayVector? position; + + factory ReplayCast.fromJson(Map json) => ReplayCast( + timeMs: json['timeMs'] as int, + subject: json['subject'] as String, + classPath: json['classPath'] as String, + position: json['position'] == null + ? null + : ReplayVector.fromJson(json['position'] as List), + ); +} + +class ReplayQuality { + const ReplayQuality({ + required this.transformVerified, + required this.decodeErrors, + this.warnings = const [], + }); + + final bool transformVerified; + final int decodeErrors; + final List warnings; + + factory ReplayQuality.fromJson(Map json) => ReplayQuality( + transformVerified: json['transformVerified'] as bool? ?? false, + decodeErrors: json['decodeErrors'] as int? ?? 0, + warnings: (json['warnings'] as List? ?? const []).cast(), + ); +} + +/// A point in game-world centimetres. +class ReplayVector { + const ReplayVector(this.x, this.y, this.z); + + final double x; + final double y; + final double z; + + factory ReplayVector.fromJson(List json) => ReplayVector( + (json[0] as num).toDouble(), + (json[1] as num).toDouble(), + (json[2] as num).toDouble(), + ); +} + +class ReplayPathPoint { + const ReplayPathPoint(this.timeMs, this.position); + + final int timeMs; + final ReplayVector position; + + factory ReplayPathPoint.fromJson(List json) => ReplayPathPoint( + json[0] as int, + ReplayVector( + (json[1] as num).toDouble(), + (json[2] as num).toDouble(), + (json[3] as num).toDouble(), + ), + ); +} + +/// A player's pose at one moment. +class ReplayPose { + const ReplayPose({ + required this.position, + required this.yaw, + required this.pitch, + }); + + final ReplayVector position; + final double yaw; + final double pitch; +} + +/// One player's movement, kept as the packed records from the blob. +class ReplayMovementTrack { + ReplayMovementTrack._(this._records, this.length); + + static const _recordBytes = 24; + + /// Two samples further apart than this are separate stretches (a death, a + /// round reset): no pose is invented between them. + static const maxInterpolationGapMs = 500; + + final ByteData _records; + final int length; + + factory ReplayMovementTrack.fromBlob( + ByteData blob, { + required int offset, + required int count, + }) { + final end = offset + count * _recordBytes; + if (offset < 0 || count < 0 || end > blob.lengthInBytes) { + throw const FormatException('Movement record range is out of bounds.'); + } + return ReplayMovementTrack._( + ByteData.sublistView(blob, offset, end), + count, + ); + } + + int timeAt(int index) => + _records.getInt32(index * _recordBytes, Endian.little); + + double _f32(int index, int field) => + _records.getFloat32(index * _recordBytes + 4 + field * 4, Endian.little); + + ReplayPose poseAtIndex(int index) => ReplayPose( + position: ReplayVector(_f32(index, 0), _f32(index, 1), _f32(index, 2)), + yaw: _f32(index, 3), + pitch: _f32(index, 4), + ); + + /// Index of the last sample at or before [timeMs], or -1. + int indexAtOrBefore(int timeMs) { + var low = 0; + var high = length - 1; + var found = -1; + while (low <= high) { + final mid = (low + high) >> 1; + if (timeAt(mid) <= timeMs) { + found = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + return found; + } + + /// The pose at [timeMs], interpolated between the samples around it. + /// Null outside the track or inside a gap longer than + /// [maxInterpolationGapMs]. + ReplayPose? poseAt(int timeMs) { + final before = indexAtOrBefore(timeMs); + if (before < 0) return null; + final beforeTime = timeAt(before); + if (before == length - 1) { + return timeMs - beforeTime <= maxInterpolationGapMs + ? poseAtIndex(before) + : null; + } + final afterTime = timeAt(before + 1); + final span = afterTime - beforeTime; + if (span > maxInterpolationGapMs) { + return timeMs - beforeTime <= maxInterpolationGapMs + ? poseAtIndex(before) + : null; + } + final a = poseAtIndex(before); + final b = poseAtIndex(before + 1); + final t = span == 0 ? 0.0 : (timeMs - beforeTime) / span; + double lerp(double from, double to) => from + (to - from) * t; + return ReplayPose( + position: ReplayVector( + lerp(a.position.x, b.position.x), + lerp(a.position.y, b.position.y), + lerp(a.position.z, b.position.z), + ), + yaw: a.yaw + _shortestAngle(a.yaw, b.yaw) * t, + pitch: lerp(a.pitch, b.pitch), + ); + } + + static double _shortestAngle(double from, double to) { + final delta = (to - from) % 360; + return delta > 180 ? delta - 360 : delta; + } +} + +/// A player's health and armor, one row per change. +class ReplayVitalsTrack { + ReplayVitalsTrack._(this._times, this._health, this._armor); + + final Int32List _times; + final Float32List _health; + final Float32List _armor; + + factory ReplayVitalsTrack.fromJson(List rows) { + final times = Int32List(rows.length); + final health = Float32List(rows.length); + final armor = Float32List(rows.length); + for (var i = 0; i < rows.length; i++) { + final row = rows[i] as List; + times[i] = row[0] as int; + health[i] = (row[1] as num).toDouble(); + armor[i] = (row[2] as num).toDouble(); + } + return ReplayVitalsTrack._(times, health, armor); + } + + /// Health and armor as of [timeMs], or null before the first row. + ({double health, double armor})? at(int timeMs) { + var low = 0; + var high = _times.length - 1; + var found = -1; + while (low <= high) { + final mid = (low + high) >> 1; + if (_times[mid] <= timeMs) { + found = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + if (found < 0) return null; + return ( + health: math.max(0.0, _health[found]), + armor: math.max(0.0, _armor[found]), + ); + } +} diff --git a/lib/replay/replay_files.dart b/lib/replay/replay_files.dart new file mode 100644 index 000000000..854ae659e --- /dev/null +++ b/lib/replay/replay_files.dart @@ -0,0 +1,187 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Where replay files come from. +enum ReplaySource { + /// Valorant's own download folder. Valorant may clear it. + valorant, + + /// Icarus's replays folder: files the user brought in from elsewhere. + icarus, +} + +/// One `.vrf` on disk. +class ReplayFile { + const ReplayFile({ + required this.path, + required this.source, + required this.sizeBytes, + required this.modified, + }); + + final String path; + final ReplaySource source; + final int sizeBytes; + final DateTime modified; + + /// Valorant names replays after their match id. + String get matchId => p.basenameWithoutExtension(path); + + /// Identifies this exact file for caching: a replay re-downloaded or + /// replaced gets a new key. + String get cacheKey => + '$matchId-$sizeBytes-${modified.millisecondsSinceEpoch}'; +} + +/// Finds replay files and keeps the decoded cache. Nothing here touches the +/// library: replays are files, and a decoded replay is a cache rebuilt from +/// its file. +class ReplayFiles { + ReplayFiles({ + Future Function()? supportDirectory, + String? valorantDemosPath, + }) : _supportDirectory = supportDirectory ?? getApplicationSupportDirectory, + _valorantDemosPath = valorantDemosPath ?? defaultValorantDemosPath(); + + final Future Function() _supportDirectory; + final String? _valorantDemosPath; + + static const extension = '.vrf'; + + /// `%LOCALAPPDATA%\VALORANT\Saved\Demos` on Windows; Valorant has no other + /// desktop platform. + static String? defaultValorantDemosPath() { + if (!Platform.isWindows) return null; + final localAppData = Platform.environment['LOCALAPPDATA']; + if (localAppData == null) return null; + return p.join(localAppData, 'VALORANT', 'Saved', 'Demos'); + } + + /// Accounts that have played Valorant on this machine. Valorant keeps a + /// `-` config folder per account; a replay is seen from + /// the team of whichever of these played in it. + Future> localSubjects() async { + final demos = _valorantDemosPath; + if (demos == null) return const {}; + final config = Directory(p.join(p.dirname(demos), 'Config')); + if (!await config.exists()) return const {}; + final subject = RegExp( + r'^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-', + caseSensitive: false, + ); + final subjects = {}; + await for (final entity in config.list(followLinks: false)) { + if (entity is! Directory) continue; + final match = subject.firstMatch(p.basename(entity.path)); + if (match != null) subjects.add(match.group(1)!.toLowerCase()); + } + return subjects; + } + + Future icarusReplaysDirectory() async => Directory( + p.join((await _supportDirectory()).path, 'replays'), + ).create(recursive: true); + + Future _cacheDirectory() async => Directory( + p.join((await _supportDirectory()).path, 'replay_cache'), + ).create(recursive: true); + + /// Every replay in both folders, newest first. A match present in both is + /// listed once, from Icarus's folder, which Valorant will not clear. + Future> list() async { + final byMatch = {}; + final valorant = _valorantDemosPath; + if (valorant != null) { + for (final file in await _scan(Directory(valorant))) { + byMatch[file.matchId] = ReplayFile( + path: file.path, + source: ReplaySource.valorant, + sizeBytes: file.sizeBytes, + modified: file.modified, + ); + } + } + for (final file in await _scan(await icarusReplaysDirectory())) { + byMatch[file.matchId] = file; + } + return byMatch.values.toList() + ..sort((a, b) => b.modified.compareTo(a.modified)); + } + + Future> _scan(Directory directory) async { + if (!await directory.exists()) return const []; + final files = []; + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) continue; + if (p.extension(entity.path).toLowerCase() != extension) continue; + final stat = await entity.stat(); + files.add( + ReplayFile( + path: entity.path, + source: ReplaySource.icarus, + sizeBytes: stat.size, + modified: stat.modified, + ), + ); + } + return files; + } + + /// Copies a replay into Icarus's folder, keeping its match-id name, and + /// returns the copy. A file already there is returned as is. + Future import(String sourcePath) async { + final directory = await icarusReplaysDirectory(); + final destination = File(p.join(directory.path, p.basename(sourcePath))); + if (!p.equals(destination.path, sourcePath) && + !await destination.exists()) { + // Copy to a temporary name first so a half-copied file is never + // listed as a replay. + final partial = File('${destination.path}.partial'); + await File(sourcePath).copy(partial.path); + await partial.rename(destination.path); + } + final stat = await destination.stat(); + return ReplayFile( + path: destination.path, + source: ReplaySource.icarus, + sizeBytes: stat.size, + modified: stat.modified, + ); + } + + Future _cacheFile(ReplayFile file, String decoderVersion) async => File( + p.join( + (await _cacheDirectory()).path, + '${file.cacheKey}-$decoderVersion.icrp', + ), + ); + + Future readCache(ReplayFile file, String decoderVersion) async { + final cache = await _cacheFile(file, decoderVersion); + if (!await cache.exists()) return null; + return cache.readAsBytes(); + } + + /// Writes the decoded buffer, then drops older caches of the same match. + Future writeCache( + ReplayFile file, + String decoderVersion, + Uint8List bytes, + ) async { + final cache = await _cacheFile(file, decoderVersion); + final partial = File('${cache.path}.partial'); + await partial.writeAsBytes(bytes, flush: true); + await partial.rename(cache.path); + await for (final entity in cache.parent.list()) { + final name = p.basename(entity.path); + if (entity is File && + name.startsWith('${file.matchId}-') && + !p.equals(entity.path, cache.path)) { + await entity.delete(); + } + } + } +} diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart new file mode 100644 index 000000000..6bf386313 --- /dev/null +++ b/lib/replay/replay_frame.dart @@ -0,0 +1,228 @@ +import 'dart:ui'; + +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/replay/replay_ability_catalog.dart'; +import 'package:icarus/replay/replay_agents.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; + +/// One moment of a replay as Icarus draws it: the same placed widgets a page +/// holds. The viewer paints these, and capturing the moment saves them. +class ReplayFrame { + const ReplayFrame({ + required this.timeMs, + required this.round, + required this.isAttack, + required this.agents, + required this.abilities, + required this.utilities, + }); + + final int timeMs; + final ReplayRound? round; + + /// Whether the perspective team attacks this round: the side the map is + /// drawn from, as in the editor. + final bool isAttack; + final List agents; + final List abilities; + final List utilities; + + List get widgets => [...utilities, ...abilities, ...agents]; +} + +/// How a player stands at one moment. +class ReplayPlayerState { + const ReplayPlayerState({ + required this.player, + required this.alive, + this.pose, + this.health, + this.armor, + }); + + final ReplayPlayer player; + final bool alive; + + /// Where they are, or where they fell. Null when they have not appeared yet + /// this round. + final ReplayPose? pose; + final double? health; + final double? armor; +} + +/// Builds frames from a decoded replay. Holds the per-replay lookups so a +/// frame per animation tick stays cheap. +class ReplayFrameBuilder { + ReplayFrameBuilder(this.document, this.projection); + + final ReplayDocument document; + final ReplayMapProjection projection; + + /// Cone length the editor allows at most; walls cut it down. + static const _coneLength = ViewConeUtility.maxLength; + + late final Map> _deathsBySubject = () { + final deaths = >{}; + for (final kill in document.kills) { + (deaths[kill.victim] ??= []).add(kill); + } + return deaths; + }(); + + /// The owner of each utility actor when the replay does not name one but + /// only one player could have cast it. + late final Map _soleCasterByAgent = () { + final byAgent = {}; + for (final player in document.players) { + final agent = replayAgentType(player.agentId); + if (agent == null) continue; + byAgent[agent] = byAgent.containsKey(agent) ? null : player; + } + return byAgent; + }(); + + /// Whether [player] is alive at [timeMs]. Health is the truth when the + /// replay has it (it sees revives); otherwise the player is dead from their + /// death until the next round starts. + bool isAlive(ReplayPlayer player, int timeMs, ReplayRound? round) { + final vitals = document.vitals[player.subject]?.at(timeMs); + if (vitals != null) return vitals.health > 0; + final roundStart = round?.startMs ?? 0; + for (final death in _deathsBySubject[player.subject] ?? const []) { + if (death.timeMs >= roundStart && death.timeMs <= timeMs) return false; + } + return true; + } + + ReplayPlayerState playerState(ReplayPlayer player, int timeMs) { + final round = document.roundAt(timeMs); + final alive = isAlive(player, timeMs, round); + final track = document.movement[player.subject]; + ReplayPose? pose = track?.poseAt(timeMs); + if (!alive && pose == null && track != null) { + // The body is gone from the stream; keep where it fell. + final death = _lastDeathBefore(player.subject, timeMs); + if (death != null) { + final index = track.indexAtOrBefore(death.timeMs); + if (index >= 0) pose = track.poseAtIndex(index); + } + } + final vitals = document.vitals[player.subject]?.at(timeMs); + return ReplayPlayerState( + player: player, + alive: alive, + pose: pose, + health: vitals?.health, + armor: vitals?.armor, + ); + } + + ReplayKill? _lastDeathBefore(String subject, int timeMs) { + ReplayKill? last; + for (final death in _deathsBySubject[subject] ?? const []) { + if (death.timeMs > timeMs) break; + last = death; + } + return last; + } + + ReplayPlayer? ownerOf(ReplayUtility utility, ReplayAbilityEntry entry) => + document.playerBySubject(utility.owner) ?? + _soleCasterByAgent[entry.agent]; + + ReplayFrame frameAt(int timeMs, {required ReplayTeam perspective}) { + final round = document.roundAt(timeMs); + final isAttack = (round?.attackingTeam ?? ReplayTeam.red) == perspective; + final coordinates = CoordinateSystem.instance; + final agentAnchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); + + final agents = []; + for (final player in document.players) { + final type = replayAgentType(player.agentId); + if (type == null) continue; + final state = playerState(player, timeMs); + final pose = state.pose; + if (pose == null) continue; + final position = + projection.toWorld(pose.position.x, pose.position.y) - agentAnchor; + final isAlly = player.team == perspective; + final id = 'replay-player-${player.subject}'; + agents.add( + state.alive + ? PlacedViewConeAgent( + id: id, + type: type, + position: position, + presetType: UtilityType.viewCone180, + rotation: projection.rotationForYaw(pose.yaw), + length: _coneLength, + isAlly: isAlly, + ) + : PlacedAgent( + id: id, + type: type, + position: position, + isAlly: isAlly, + state: AgentState.dead, + ), + ); + } + + final abilities = []; + for (final utility in document.utility) { + if (utility.spawnMs > timeMs) break; + if (!utility.isActiveAt(timeMs)) continue; + final entry = replayAbilityFor(utility.classPath); + if (entry == null) continue; + final owner = ownerOf(utility, entry); + // An unknown owner among duplicate agents would be a guess at the + // team colour; leave it out instead. + if (owner == null) continue; + abilities.add( + entry.place( + utility: utility, + projection: projection, + timeMs: timeMs, + id: 'replay-utility-${utility.id}', + isAlly: owner.team == perspective, + ), + ); + } + + final utilities = []; + final plant = round?.plant; + if (round != null && + plant != null && + timeMs >= plant.timeMs && + timeMs < round.endMs) { + final spike = PlacedUtility( + id: 'replay-spike-${round.index}', + type: UtilityType.spike, + position: Offset.zero, + ); + spike.position = projection.toWorld(plant.position.x, plant.position.y) - + coordinates.virtualOffsetToWorld( + storedUtilityAnchor( + utility: spike, + mapScale: Maps.mapScale[projection.map]!, + ), + ); + utilities.add(spike); + } + + return ReplayFrame( + timeMs: timeMs, + round: round, + isAttack: isAttack, + agents: agents, + abilities: abilities, + utilities: utilities, + ); + } +} diff --git a/lib/replay/replay_loader.dart b/lib/replay/replay_loader.dart new file mode 100644 index 000000000..0fe0a17cc --- /dev/null +++ b/lib/replay/replay_loader.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; + +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_files.dart'; + +/// Opens one replay: from the decoded cache when this decoder has read the +/// file before, otherwise by decoding it and caching the result. +class ReplayLoader { + ReplayLoader({required this.files, required this.file, required this.probe}); + + final ReplayFiles files; + final ReplayFile file; + final ReplayProbe probe; + + ReplayDecodeJob? _job; + + /// 0 to 1 while decoding; 1 once read from the cache. + double get progress => _job?.progress ?? 0; + + /// Stops a decode in progress; [load] then fails as cancelled. + void cancel() => _job?.cancel(); + + Future load() async { + final cached = await files.readCache(file, probe.decoderVersion); + if (cached != null) { + try { + return ReplayDocument.fromBytes(cached); + } on FormatException { + // A cache from an older format: decode again below. + } + } + final job = _job = decodeReplay(file.path); + final Uint8List bytes = await job.result; + final document = ReplayDocument.fromBytes(bytes); + // A failed cache write only costs a decode next time. + try { + await files.writeCache(file, probe.decoderVersion, bytes); + } on Exception catch (_) {} + return document; + } +} diff --git a/lib/replay/replay_map_projection.dart b/lib/replay/replay_map_projection.dart new file mode 100644 index 000000000..2dba793d0 --- /dev/null +++ b/lib/replay/replay_map_projection.dart @@ -0,0 +1,158 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:icarus/const/map_artwork_registration.dart'; +import 'package:icarus/const/maps.dart'; + +/// Places Valorant game-world positions on Icarus's maps. +/// +/// A replay gives positions in Unreal centimetres. Each map's attack SVG was +/// registered to the game's geometry in "native" metres, which is Unreal with +/// Y flipped (the geometry went through Blender): native = (x, -y) / 100. +/// Real replay positions confirm that convention; every other swap or sign +/// puts a third or more of the players off the map. +class ReplayMapProjection { + ReplayMapProjection._(this.map, this._native) + : _viewBox = Maps.mapViewBox[map]! { + _scale = math.min( + _mapWidth / _viewBox.width, + _worldHeight / _viewBox.height, + ); + _offset = Offset( + (_worldWidth - _viewBox.width * _scale) / 2, + (_worldHeight - _viewBox.height * _scale) / 2, + ); + } + + /// null when the map path isn't one of Icarus's maps. + static ReplayMapProjection? forMapPath(String mapPath) { + final segments = mapPath.split('/'); + final folder = segments.indexOf('Maps'); + if (folder < 0 || folder + 1 >= segments.length) return null; + final map = mapsByCodename[segments[folder + 1]]; + return map == null ? null : forMap(map); + } + + static ReplayMapProjection forMap(MapValue map) => _projections.putIfAbsent( + map, () => ReplayMapProjection._(map, _nativeToAttackSvg[map]!)); + + static final _projections = {}; + + /// Riot's folder names under /Game/Maps/, from valorant-api.com /v1/maps + /// `mapUrl` and the paths replays record. + static const mapsByCodename = { + 'Ascent': MapValue.ascent, + 'Duality': MapValue.bind, + 'Triad': MapValue.haven, + 'Bonsai': MapValue.split, + 'Port': MapValue.icebox, + 'Foxtrot': MapValue.breeze, + 'Canyon': MapValue.fracture, + 'Pitt': MapValue.pearl, + 'Jam': MapValue.lotus, + 'Juliett': MapValue.sunset, + 'Infinity': MapValue.abyss, + 'Rook': MapValue.corrode, + 'Plummet': MapValue.summit, + }; + + // The canvas frame SvgHeightMapTransform draws the SVG into. + static const _worldHeight = 1000.0; + static const _mapWidth = 1240.0; + static const _worldWidth = _worldHeight * 16 / 9; + + final MapValue map; + final _Affine _native; + final Size _viewBox; + late final double _scale; + late final Offset _offset; + + /// Game-world cm (Unreal x, y) -> the point on the attack-side SVG. + Offset attackSvgPoint(double x, double y) => _native.apply(x / 100, -y / 100); + + /// Game-world cm (Unreal x, y) -> the point on the defense-side SVG, which + /// is the attack artwork turned half way round and nudged by its measured + /// offset. + Offset defenseSvgPoint(double x, double y) { + final attack = attackSvgPoint(x, y); + return Offset(_viewBox.width, _viewBox.height) - + attack + + mapDefenseArtworkOffsetSvg[map]!; + } + + /// Game-world cm (Unreal x, y) -> Icarus's canonical attack-side world point, + /// the same space PlacedWidget.position is saved in (CoordinateSystem normalized + /// 1777.8 x 1000 world, map centred). The defense view is derived by + /// CoordinateSystem.positionForSide, so this is always the attack-side point. + /// + /// This is where the player stands. A widget's saved position is its top + /// left, so subtract the widget's anchor before saving. + Offset toWorld(double x, double y) => _offset + attackSvgPoint(x, y) * _scale; + + /// Game yaw in degrees -> the canonical (attack-side) rotation in radians, + /// stored the way PlacedViewConeAgent.rotation / PlacedAbility.rotation are, + /// i.e. what you'd save so the cone/ability points the way the player looked. + /// + /// Icarus rotation 0 points up the screen and grows clockwise: a drag + /// toward screen direction (dx, dy) saves atan2(dy, dx) + pi / 2. + double rotationForYaw(double yawDegrees) { + final yaw = yawDegrees * math.pi / 180; + final direction = _native.applyLinear(math.cos(yaw), -math.sin(yaw)); + return math.atan2(direction.dy, direction.dx) + math.pi / 2; + } + + /// Game centimetres -> Icarus world units at this map's scale (for lengths + /// such as wall lengths and ranges). + double worldLength(double centimetres) => + centimetres / 100 * _native.scale * _scale; + + // Native metres -> attack SVG, `nativeToAttackSvg` in + // E:\IcarusWorldAudit\2026-09-06\tactical-alignment-sides-v1\.json. + // Each composes Riot's minimap UIData transform with the minimap image's + // registration to the SVG (registration/results/-registration.json, + // held-out wall corners within 0.6 to 1.0 SVG units RMS). + static const _nativeToAttackSvg = { + MapValue.ascent: _Affine(3.2969024617778473, 0.0, 161.53717490897037, 0.0, + -3.2969024617778473, 382.7566201327165), + MapValue.breeze: _Affine(0.0, -3.328286212568514, 206.72081203021492, + -3.328286212568514, 0.0, 399.09339093388206), + MapValue.lotus: _Affine(0.0, -4.035511936870364, 224.66388337076853, + -4.035511936870364, 0.0, 466.17819818095813), + MapValue.icebox: _Affine(-3.356078076230773, 0.0, 107.3159767810122, 0.0, + 3.3560780762308013, 269.01824348297737), + MapValue.sunset: _Affine(0.0, -3.477221798286024, 204.47250012145628, + -3.4772217982860525, 0.0, 243.89380298905087), + MapValue.split: _Affine(3.9096202760835865, 0.0, 138.54452327926526, 0.0, + -3.909620276083558, 413.8068381306813), + MapValue.haven: _Affine(3.508288059722588, 0.0, 130.96278361326088, 0.0, + -3.508288059722645, 513.7054628036302), + MapValue.fracture: _Affine(0.0, -3.9644760557984, 272.4131479231745, + -3.9644760557984, 0.0, 570.5067774183195), + MapValue.abyss: _Affine(3.7696493669158144, 0.0, 226.36573016122568, 0.0, + -3.7696493669158144, 236.55264870976578), + MapValue.pearl: _Affine(0.0, -3.678878574846294, 225.5718974563849, + -3.678878574846351, 0.0, 432.9945225279285), + MapValue.bind: _Affine(0.0, -2.7287645932343594, 225.8488984336913, + -2.7287645932343594, 0.0, 460.3265678718115), + MapValue.corrode: _Affine(3.207988614324904, 0.0, 193.13706506207424, 0.0, + -3.207988614324904, 244.73547402968953), + MapValue.summit: _Affine(0.0, -3.3971209017364643, 4.613976772523586, + -3.397120901736457, 0.0, 453.75554061572274), + }; +} + +/// Row-major 2x3 matrix: (x, y) -> (a x + b y + c, d x + e y + f). +class _Affine { + const _Affine(this.a, this.b, this.c, this.d, this.e, this.f); + + final double a, b, c, d, e, f; + + Offset apply(double x, double y) => + Offset(a * x + b * y + c, d * x + e * y + f); + + Offset applyLinear(double x, double y) => + Offset(a * x + b * y, d * x + e * y); + + /// SVG units per native metre; every map's matrix is a uniform scale. + double get scale => math.sqrt((a * e - b * d).abs()); +} diff --git a/lib/replay/replay_playback.dart b/lib/replay/replay_playback.dart new file mode 100644 index 000000000..76a865a99 --- /dev/null +++ b/lib/replay/replay_playback.dart @@ -0,0 +1,115 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; + +/// Playback state for one open replay: the clock, the speed, and whose eyes +/// the match is seen through. The view drives [advance] from a ticker. +class ReplayPlayback extends ChangeNotifier { + ReplayPlayback({ + required this.document, + required ReplayMapProjection projection, + required ReplayTeam perspective, + }) : frames = ReplayFrameBuilder(document, projection), + _perspective = perspective { + final first = document.rounds.isEmpty ? null : document.rounds.first; + _timeMs = first?.playStartMs ?? 0; + } + + static const speeds = [0.5, 1.0, 2.0, 4.0]; + + final ReplayDocument document; + final ReplayFrameBuilder frames; + + int _timeMs = 0; + bool _playing = false; + double _speed = 1; + ReplayTeam _perspective; + + int get timeMs => _timeMs; + bool get playing => _playing; + double get speed => _speed; + ReplayTeam get perspective => _perspective; + + int get durationMs => document.match.durationMs; + ReplayRound? get round => document.roundAt(_timeMs); + + ReplayFrame? _frame; + ReplayFrame get frame { + final cached = _frame; + if (cached != null && + cached.timeMs == _timeMs && + _framePerspective == _perspective) { + return cached; + } + _framePerspective = _perspective; + return _frame = frames.frameAt(_timeMs, perspective: _perspective); + } + + ReplayTeam? _framePerspective; + + void play() { + if (_playing) return; + if (_timeMs >= durationMs) _timeMs = round?.playStartMs ?? 0; + _playing = true; + notifyListeners(); + } + + void pause() { + if (!_playing) return; + _playing = false; + notifyListeners(); + } + + void togglePlaying() => _playing ? pause() : play(); + + set speed(double value) { + if (_speed == value) return; + _speed = value; + notifyListeners(); + } + + set perspective(ReplayTeam value) { + if (_perspective == value) return; + _perspective = value; + notifyListeners(); + } + + void seek(int timeMs) { + final clamped = timeMs.clamp(0, durationMs); + if (clamped == _timeMs) return; + _timeMs = clamped; + notifyListeners(); + } + + /// Moves the clock by [elapsed] of wall time at the current speed. + void advance(Duration elapsed) { + if (!_playing) return; + final next = _timeMs + (elapsed.inMicroseconds * _speed / 1000).round(); + _timeMs = math.min(next, durationMs); + if (_timeMs >= durationMs) _playing = false; + notifyListeners(); + } + + /// Jumps to where play starts in round [index]. + void seekRound(int index) { + if (index < 0 || index >= document.rounds.length) return; + seek(document.rounds[index].playStartMs); + } + + void nextRound() => seekRound((round?.index ?? -1) + 1); + + /// The start of this round, or the previous round when already near its + /// start, like a music player's back button. + void previousRound() { + final current = round; + if (current == null) return; + if (_timeMs - current.playStartMs > 2000) { + seekRound(current.index); + } else { + seekRound(current.index - 1); + } + } +} diff --git a/lib/replay/replay_weapons.dart b/lib/replay/replay_weapons.dart new file mode 100644 index 000000000..41f061239 --- /dev/null +++ b/lib/replay/replay_weapons.dart @@ -0,0 +1,34 @@ +import 'package:icarus/const/weapons.dart'; + +/// Icarus weapons by the equippable folder Valorant keeps each gun in, e.g. +/// `/Game/Equippables/Guns/Rifles/AK/...` is the Vandal. Checked against the +/// equippable paths replays carry; an unknown path shows no weapon. +const List<(String, WeaponType)> _weaponsByPathPart = [ + ('/Sidearms/BasePistol/', WeaponType.classic), + ('/Sidearms/Shotgun/', WeaponType.shorty), + ('/Sidearms/AutomaticPistol/', WeaponType.frenzy), + ('/Sidearms/Luger/', WeaponType.ghost), + ('/Sidearms/Revolver/', WeaponType.sheriff), + ('/Sidearms/Slim/', WeaponType.bandit), + ('/SubMachineGuns/Vector/', WeaponType.stinger), + ('/SubMachineGuns/MP5/', WeaponType.spectre), + ('/Shotguns/PumpShotgun/', WeaponType.bucky), + ('/Shotguns/AutomaticShotgun/', WeaponType.judge), + ('/Rifles/Burst/', WeaponType.bulldog), + ('/Rifles/DMR/', WeaponType.guardian), + ('/Rifles/Carbine/', WeaponType.phantom), + ('/Rifles/AK/', WeaponType.vandal), + ('/Sniper/LeverSniperRifle/', WeaponType.marshal), + ('/Sniper/DoubleSniper/', WeaponType.outlaw), + ('/Sniper/BoltSniper/', WeaponType.operator), + ('/HvyMachineGuns/LMG/', WeaponType.ares), + ('/HvyMachineGuns/HMG/', WeaponType.odin), +]; + +WeaponType replayWeaponType(String? equippablePath) { + if (equippablePath == null) return WeaponType.none; + for (final (part, weapon) in _weaponsByPathPart) { + if (equippablePath.contains(part)) return weapon; + } + return WeaponType.none; +} diff --git a/lib/replay_view.dart b/lib/replay_view.dart new file mode 100644 index 000000000..70dbc0236 --- /dev/null +++ b/lib/replay_view.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_files.dart'; +import 'package:icarus/replay/replay_loader.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/widgets/replay/replay_canvas.dart'; +import 'package:icarus/widgets/replay/replay_dock.dart'; +import 'package:icarus/widgets/replay/replay_match_card.dart'; +import 'package:icarus/widgets/replay/replay_roster.dart'; +import 'package:icarus/widgets/window_chrome.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Watches one Valorant replay on the Icarus map. The map, the players and +/// their utility are the editor's own widgets, so whatever is on screen can +/// be captured into a strategy as it stands. +class ReplayView extends ConsumerStatefulWidget { + const ReplayView({super.key, required this.file, required this.probe}); + + final ReplayFile file; + final ReplayProbe probe; + + static Route route({ + required ReplayFile file, + required ReplayProbe probe, + }) => + PageRouteBuilder( + pageBuilder: (_, __, ___) => ReplayView(file: file, probe: probe), + transitionDuration: const Duration(milliseconds: 200), + reverseTransitionDuration: const Duration(milliseconds: 150), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ); + + @override + ConsumerState createState() => _ReplayViewState(); +} + +class _ReplayViewState extends ConsumerState + with SingleTickerProviderStateMixin { + late final ReplayLoader _loader; + late final Ticker _ticker; + Timer? _progressTimer; + + ReplayPlayback? _playback; + ReplayDecodeException? _error; + Duration _lastTick = Duration.zero; + bool? _drawnAsAttack; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + _loader = ReplayLoader( + files: ref.read(replayFilesProvider), + file: widget.file, + probe: widget.probe, + ); + // Progress lives in native memory; repaint the bar while decoding. + _progressTimer = Timer.periodic( + const Duration(milliseconds: 100), + (_) => setState(() {}), + ); + _open(); + } + + Future _open() async { + final projection = ReplayMapProjection.forMapPath(widget.probe.mapPath); + try { + if (projection == null) { + throw const ReplayDecodeException( + 'unsupportedMap', + "This replay's map isn't in Icarus yet.", + ); + } + final document = await _loader.load(); + final perspective = await _defaultPerspective(document); + if (!mounted) return; + final playback = ReplayPlayback( + document: document, + projection: projection, + perspective: perspective, + )..addListener(_onPlaybackChanged); + setState(() => _playback = playback); + _syncMapSide(playback); + _ticker.start(); + } on ReplayDecodeException catch (error) { + if (mounted) setState(() => _error = error); + } on FormatException catch (error) { + if (mounted) { + setState( + () => _error = ReplayDecodeException('corrupt', error.message)); + } + } finally { + _progressTimer?.cancel(); + } + } + + /// The team of whoever played this match on this machine, else Red. + Future _defaultPerspective(ReplayDocument document) async { + final local = await ref.read(replayFilesProvider).localSubjects(); + for (final player in document.players) { + if (local.contains(player.subject.toLowerCase())) return player.team; + } + return ReplayTeam.red; + } + + void _onTick(Duration elapsed) { + final delta = elapsed - _lastTick; + _lastTick = elapsed; + _playback?.advance(delta); + } + + void _onPlaybackChanged() { + final playback = _playback; + if (playback != null) _syncMapSide(playback); + } + + /// The editor's map widgets read the map and side from [mapProvider]; the + /// side follows the perspective team round by round. + void _syncMapSide(ReplayPlayback playback) { + final isAttack = playback.frame.isAttack; + if (_drawnAsAttack == isAttack) return; + _drawnAsAttack = isAttack; + ref.read(mapProvider.notifier).fromHive( + ReplayMapProjection.forMapPath(widget.probe.mapPath)!.map, + isAttack, + ); + } + + @override + void dispose() { + _progressTimer?.cancel(); + _loader.cancel(); + _ticker.dispose(); + _playback + ?..removeListener(_onPlaybackChanged) + ..dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + final playback = _playback; + if (playback == null || event is KeyUpEvent) return KeyEventResult.ignored; + switch (event.logicalKey) { + case LogicalKeyboardKey.space: + if (event is KeyRepeatEvent) return KeyEventResult.handled; + playback.togglePlaying(); + case LogicalKeyboardKey.arrowLeft: + playback.seek(playback.timeMs - 5000); + case LogicalKeyboardKey.arrowRight: + playback.seek(playback.timeMs + 5000); + case LogicalKeyboardKey.arrowUp: + playback.previousRound(); + case LogicalKeyboardKey.arrowDown: + playback.nextRound(); + default: + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + final playback = _playback; + final map = ReplayMapProjection.forMapPath(widget.probe.mapPath)?.map; + return Scaffold( + body: Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Column( + children: [ + _ReplayStrip(map: map, probe: widget.probe), + Expanded( + child: playback != null + ? _ReplayBench(playback: playback, map: map!) + : _ReplayLoading( + progress: _loader.progress, + error: _error, + onBack: () => Navigator.pop(context), + ), + ), + ], + ), + ), + ); + } +} + +class _ReplayStrip extends StatelessWidget { + const _ReplayStrip({required this.map, required this.probe}); + + final MapValue? map; + final ReplayProbe probe; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return AppWindowStrip( + child: Stack( + children: [ + Row( + children: [ + const SizedBox(width: 6), + ShadTooltip( + builder: (context) => const Text('Replays'), + child: ShadIconButton.ghost( + width: 28, + height: 28, + foregroundColor: theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: () => Navigator.pop(context), + icon: const Icon(LucideIcons.house300, size: 18), + ), + ), + const IcarusWordmark(), + const Expanded(child: WindowDragArea(child: SizedBox.expand())), + ], + ), + Center( + child: IgnorePointer( + child: Text( + [ + if (map != null) replayMapName(map!), + MaterialLocalizations.of(context).formatMediumDate( + probe.recordedAt ?? DateTime.now(), + ), + ].join(' · '), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.foreground, + ), + ), + ), + ), + ], + ), + ); + } +} + +String replayMapName(MapValue map) { + final name = Maps.mapNames[map]!; + return name[0].toUpperCase() + name.substring(1); +} + +/// The canvas with its three floating panels, laid out like the editor: +/// match and actions top-left, roster on the right, timeline along the +/// bottom. +class _ReplayBench extends StatelessWidget { + const _ReplayBench({required this.playback, required this.map}); + + final ReplayPlayback playback; + final MapValue map; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: RepaintBoundary( + child: ReplayCanvas( + playback: playback, + map: map, + reservedRight: ReplayRoster.width + 16, + ), + ), + ), + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: ReplayMatchCard(playback: playback, map: map), + ), + ), + Positioned( + top: 8, + right: 8, + bottom: 8, + child: ReplayRoster(playback: playback), + ), + Positioned( + left: 8, + right: ReplayRoster.width + 16, + bottom: 8, + child: Center(child: ReplayDock(playback: playback)), + ), + ], + ); + } +} + +class _ReplayLoading extends StatelessWidget { + const _ReplayLoading({ + required this.progress, + required this.error, + required this.onBack, + }); + + final double progress; + final ReplayDecodeException? error; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final failure = error; + return Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + failure == null ? 'Reading replay' : "Couldn't open this replay", + style: theme.textTheme.p, + ), + const SizedBox(height: 12), + if (failure == null) + ShadProgress(value: progress <= 0 ? null : progress) + else ...[ + Text(failure.message, style: theme.textTheme.muted), + const SizedBox(height: 16), + ShadButton.secondary( + onPressed: onBack, + child: const Text('Back to replays'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index e8a59a14a..f84cfbf03 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; @@ -22,6 +23,7 @@ import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; import 'package:icarus/widgets/folder_content.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/replay/replay_library_content.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class FolderNavigator extends ConsumerStatefulWidget { @@ -254,25 +256,27 @@ class _FolderNavigatorState extends ConsumerState { onExportLibrary: handleExportLibrary, ), Expanded( - child: ShadContextMenuRegion( - controller: _backgroundMenuController, - items: [ - ShadContextMenuItem( - leading: const Icon(LucideIcons.folderPlus), - onPressed: showCreateFolderDialog, - child: const Text('Create Folder'), - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.filePlus), - onPressed: showCreateDialog, - child: const Text('Create Strategy'), - ), - ], - child: FolderContent( - folder: currentFolder, - onCreateStrategy: showCreateDialog, - ), - ), + child: ref.watch(libraryTabProvider) == LibraryTab.replays + ? const ReplayLibraryContent() + : ShadContextMenuRegion( + controller: _backgroundMenuController, + items: [ + ShadContextMenuItem( + leading: const Icon(LucideIcons.folderPlus), + onPressed: showCreateFolderDialog, + child: const Text('Create Folder'), + ), + ShadContextMenuItem( + leading: const Icon(LucideIcons.filePlus), + onPressed: showCreateDialog, + child: const Text('Create Strategy'), + ), + ], + child: FolderContent( + folder: currentFolder, + onCreateStrategy: showCreateDialog, + ), + ), ), ], ), diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 5249eb024..419e7fbdd 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/widgets/custom_search_field.dart'; import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/strip_status_icons.dart'; @@ -24,7 +27,7 @@ const double _menuItemGap = 8; const double _menuLabelLeftInset = _menuItemHorizontalPadding + _menuIconWidth + _menuItemGap; -/// The library's only chrome: tabs on the left, search / sort / New on the +/// The library's only chrome: tabs on the left, the open tab's actions on the /// right, all inside the window's title strip. class LibraryTitleStrip extends ConsumerStatefulWidget { const LibraryTitleStrip({ @@ -59,6 +62,7 @@ class _LibraryTitleStripState extends ConsumerState { @override Widget build(BuildContext context) { + final tab = ref.watch(libraryTabProvider); return AppWindowStrip( child: Row( children: [ @@ -69,10 +73,29 @@ class _LibraryTitleStripState extends ConsumerState { icon: LucideIcons.folder, label: 'My Library', semanticsLabel: 'My Library', - selected: true, - onTap: () => ref.read(folderProvider.notifier).updateID(null), + selected: tab == LibraryTab.strategies, + onTap: () { + ref.read(libraryTabProvider.notifier).state = + LibraryTab.strategies; + ref.read(folderProvider.notifier).updateID(null); + }, ), const SizedBox(width: _tabGap), + if (!kIsWeb) ...[ + _TabButton( + key: const ValueKey('library-tab-replays'), + icon: LucideIcons.film, + label: 'Replays', + semanticsLabel: 'Replays', + selected: tab == LibraryTab.replays, + onTap: () { + ref.read(libraryTabProvider.notifier).state = + LibraryTab.replays; + ref.read(replayLibraryProvider.notifier).refresh(); + }, + ), + const SizedBox(width: _tabGap), + ], // Shared and Community have nowhere to go yet; they hold their // place so the library's shape does not move when they land. _TabButton( @@ -106,26 +129,64 @@ class _LibraryTitleStripState extends ConsumerState { ), const WhatsNewIcon(), const SizedBox(width: 4), - const SizedBox( - height: _controlHeight, - child: SearchTextField( - key: ValueKey('library-search'), - collapsedWidth: _controlHeight, - expandedWidth: 220, - compact: true, - hintText: 'Search', + if (tab == LibraryTab.replays) + _buildAddReplayButton() + else ...[ + const SizedBox( + height: _controlHeight, + child: SearchTextField( + key: ValueKey('library-search'), + collapsedWidth: _controlHeight, + expandedWidth: 220, + compact: true, + hintText: 'Search', + ), ), - ), - const SizedBox(width: 4), - _buildSortMenu(), - const SizedBox(width: 8), - _buildNewMenu(), + const SizedBox(width: 4), + _buildSortMenu(), + const SizedBox(width: 8), + _buildNewMenu(), + ], const SizedBox(width: 10), ], ), ); } + /// Replays Valorant downloads list themselves; this brings in one saved + /// anywhere else. + Widget _buildAddReplayButton() { + return ShadButton( + key: const ValueKey('library-add-replay'), + height: _controlHeight, + padding: const EdgeInsets.only(left: 8, right: 10), + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['vrf'], + allowMultiple: true, + ); + if (result == null) return; + for (final file in result.files) { + final path = file.path; + if (path == null) continue; + try { + await ref.read(replayLibraryProvider.notifier).import(path); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + "Couldn't add ${file.name}.", + error: error, + stackTrace: stackTrace, + source: 'LibraryTitleStrip.addReplay', + ); + } + } + }, + child: const Text('Add Replay'), + ); + } + void _comingSoon(String what) { Settings.showToast( message: "$what aren't ready yet. Coming very soon.", diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index c65d52dd3..3828585ea 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -804,7 +804,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final widget in orderedWidgets) - _widgetView( + staticPlacedWidgetView( widget: widget, mapScale: mapScale, abilitySize: abilitySize, @@ -815,116 +815,123 @@ class TemporaryWidgetBuilder extends ConsumerWidget { ), ); } +} - Widget _widgetView({ - required PlacedWidget widget, - required double mapScale, - required double abilitySize, - required double agentSize, - required bool isAttack, - }) { - final coord = CoordinateSystem.instance; - final scaledPosition = _overlayScreenPosition( - widget: widget, - coordinateSystem: coord, - agentSize: agentSize, - mapScale: mapScale, - abilitySize: abilitySize, - isAttack: isAttack, - ); - final canonicalRotation = PageTransitionEntry.rotationOf(widget); - final displayRotation = canonicalRotation == null - ? null - : coord.rotationForSide(canonicalRotation, isAttack: isAttack); - - if (widget is PlacedUtility && - UtilityData.usesRotation(widget.type) && - displayRotation != null && - displayRotation != 0) { - return Positioned( - left: scaledPosition.dx, - top: scaledPosition.dy, - child: Transform.rotate( - angle: displayRotation, - alignment: Alignment.topLeft, - origin: utilityAnchorForScale( - utility: widget, - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - ).scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ), - child: PlacedWidgetPreview.build( - widget, - mapScale, - length: widget.length, - rotation: displayRotation, - agentSize: agentSize, - abilitySize: abilitySize, - ), - ), - ); - } else if (widget is PlacedAbility && - displayRotation != null && - displayRotation != 0 && - widget.data.abilityData != null && - !(widget.visualState.showVisionCone && - AbilityVisionConeSpec.forAbility(widget.data) != null) && - isRotatable(widget.data.abilityData!)) { - return Positioned( - left: scaledPosition.dx, - top: scaledPosition.dy, - child: Transform.rotate( - angle: displayRotation, - alignment: Alignment.topLeft, - origin: (widget) - .data - .abilityData! - .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) - .scale(coord.scaleFactor, coord.scaleFactor), - child: PlacedWidgetPreview.build( - widget, - mapScale, - length: widget.length, - rotation: displayRotation, - armLengthsMeters: widget.armLengthsMeters, - agentSize: agentSize, - abilitySize: abilitySize, - ), +/// One placed widget drawn where a page puts it, without the editor's handles +/// or gestures. The transition's still frame and the replay viewer share it, +/// so both match the editor exactly. +Widget staticPlacedWidgetView({ + required PlacedWidget widget, + required double mapScale, + required double abilitySize, + required double agentSize, + required bool isAttack, +}) { + final coord = CoordinateSystem.instance; + final scaledPosition = _overlayScreenPosition( + widget: widget, + coordinateSystem: coord, + agentSize: agentSize, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: isAttack, + ); + final canonicalRotation = PageTransitionEntry.rotationOf(widget); + final displayRotation = canonicalRotation == null + ? null + : coord.rotationForSide(canonicalRotation, isAttack: isAttack); + + if (widget is PlacedUtility && + UtilityData.usesRotation(widget.type) && + displayRotation != null && + displayRotation != 0) { + return Positioned( + key: ValueKey(widget.id), + left: scaledPosition.dx, + top: scaledPosition.dy, + child: Transform.rotate( + angle: displayRotation, + alignment: Alignment.topLeft, + origin: utilityAnchorForScale( + utility: widget, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + ).scale( + CoordinateSystem.instance.scaleFactor, + CoordinateSystem.instance.scaleFactor, ), - ); - } else if (widget is PlacedText || widget is PlacedImage) { - return CanonicalPositionedBox( - attackScreenPosition: scaledPosition, - isAttack: isAttack, child: PlacedWidgetPreview.build( widget, mapScale, - length: widget is PlacedAbility ? widget.length : null, + length: widget.length, rotation: displayRotation, - armLengthsMeters: - widget is PlacedAbility ? widget.armLengthsMeters : null, agentSize: agentSize, abilitySize: abilitySize, ), - ); - } else { - return Positioned( - left: scaledPosition.dx, - top: scaledPosition.dy, + ), + ); + } else if (widget is PlacedAbility && + displayRotation != null && + displayRotation != 0 && + widget.data.abilityData != null && + !(widget.visualState.showVisionCone && + AbilityVisionConeSpec.forAbility(widget.data) != null) && + isRotatable(widget.data.abilityData!)) { + return Positioned( + key: ValueKey(widget.id), + left: scaledPosition.dx, + top: scaledPosition.dy, + child: Transform.rotate( + angle: displayRotation, + alignment: Alignment.topLeft, + origin: (widget) + .data + .abilityData! + .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) + .scale(coord.scaleFactor, coord.scaleFactor), child: PlacedWidgetPreview.build( widget, mapScale, - length: widget is PlacedAbility ? widget.length : null, + length: widget.length, rotation: displayRotation, - armLengthsMeters: - widget is PlacedAbility ? widget.armLengthsMeters : null, + armLengthsMeters: widget.armLengthsMeters, agentSize: agentSize, abilitySize: abilitySize, ), - ); - } + ), + ); + } else if (widget is PlacedText || widget is PlacedImage) { + return CanonicalPositionedBox( + key: ValueKey(widget.id), + attackScreenPosition: scaledPosition, + isAttack: isAttack, + child: PlacedWidgetPreview.build( + widget, + mapScale, + length: widget is PlacedAbility ? widget.length : null, + rotation: displayRotation, + armLengthsMeters: + widget is PlacedAbility ? widget.armLengthsMeters : null, + agentSize: agentSize, + abilitySize: abilitySize, + ), + ); + } else { + return Positioned( + key: ValueKey(widget.id), + left: scaledPosition.dx, + top: scaledPosition.dy, + child: PlacedWidgetPreview.build( + widget, + mapScale, + length: widget is PlacedAbility ? widget.length : null, + rotation: displayRotation, + armLengthsMeters: + widget is PlacedAbility ? widget.armLengthsMeters : null, + agentSize: agentSize, + abilitySize: abilitySize, + ), + ); } } diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart new file mode 100644 index 000000000..25be9f884 --- /dev/null +++ b/lib/widgets/replay/replay_canvas.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/widgets/canonical_map_artwork.dart'; +import 'package:icarus/widgets/dot_painter.dart'; +import 'package:icarus/widgets/map_svg_color_mapper.dart'; +import 'package:icarus/widgets/page_transition_overlay.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// The map as the editor draws it, with the replay's current moment on top. +/// Pans and zooms like the editor; [reservedRight] keeps the map clear of the +/// roster panel. +class ReplayCanvas extends ConsumerStatefulWidget { + const ReplayCanvas({ + super.key, + required this.playback, + required this.map, + required this.reservedRight, + }); + + final ReplayPlayback playback; + final MapValue map; + final double reservedRight; + + @override + ConsumerState createState() => _ReplayCanvasState(); +} + +class _ReplayCanvasState extends ConsumerState { + final _controller = TransformationController(); + Size? _lastViewport; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// The theme a captured strategy starts with, so the map looks the same + /// here and in the editor afterwards. + MapSvgColorMapper _colorMapper() { + final profiles = ref.watch(mapThemeProfilesProvider); + final profile = profiles.profiles.firstWhere( + (profile) => profile.id == profiles.defaultProfileIdForNewStrategies, + orElse: () => MapThemeProfilesProvider.immutableDefaultProfile, + ); + return MapSvgColorMapper.forPalette(profile.palette); + } + + @override + Widget build(BuildContext context) { + final colorMapper = _colorMapper(); + // Marker sizes a captured strategy starts with, for the same reason. + final preferences = ref.watch(appPreferencesProvider); + return LayoutBuilder( + builder: (context, constraints) { + final height = constraints.maxHeight; + final worldWidth = height * 16 / 9; + CoordinateSystem(playAreaSize: Size(worldWidth, height)); + final coordinates = CoordinateSystem.instance; + final viewportWidth = (constraints.maxWidth - widget.reservedRight) + .clamp(0.0, constraints.maxWidth); + final viewport = Size(viewportWidth, height); + if (_lastViewport != viewport) { + _lastViewport = viewport; + _controller.value = Matrix4.identity() + ..translateByDouble((viewportWidth - worldWidth) / 2, 0, 0, 1); + } + final mapWidth = height * coordinates.mapAspectRatio; + final mapLeft = (worldWidth - mapWidth) / 2; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + radius: 1.5, + colors: [ + const Color(0xff18181b), + ShadTheme.of(context).colorScheme.background, + ], + ), + ), + child: InteractiveViewer( + transformationController: _controller, + constrained: false, + alignment: Alignment.topLeft, + minScale: 1, + maxScale: 8, + boundaryMargin: const EdgeInsets.all(double.infinity), + child: SizedBox( + width: worldWidth, + height: height, + child: ListenableBuilder( + listenable: widget.playback, + builder: (context, _) { + final frame = widget.playback.frame; + final isAttack = frame.isAttack; + final mapName = Maps.mapNames[widget.map]; + final widgets = frame.widgets + ..sort(PageLayering.comparePlacedWidgets); + return Stack( + clipBehavior: Clip.none, + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4), + child: RepaintBoundary(child: DotGrid()), + ), + ), + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: RepaintBoundary( + child: CanonicalMapArtwork( + map: widget.map, + isAttack: isAttack, + child: SvgPicture.asset( + 'assets/maps/${mapName}_map' + '${isAttack ? '' : '_defense'}.svg', + colorMapper: colorMapper, + fit: BoxFit.contain, + ), + ), + ), + ), + for (final placed in widgets) + staticPlacedWidgetView( + widget: placed, + mapScale: Maps.mapScale[widget.map]!, + abilitySize: + preferences.defaultAbilitySizeForNewStrategies, + agentSize: + preferences.defaultAgentSizeForNewStrategies, + isAttack: isAttack, + ), + ], + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/replay/replay_dock.dart b/lib/widgets/replay/replay_dock.dart new file mode 100644 index 000000000..0fa25d838 --- /dev/null +++ b/lib/widgets/replay/replay_dock.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/widgets/replay/replay_match_card.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Playback along the bottom: every round of the match, then the round being +/// watched with its kills and plant marked. +class ReplayDock extends StatelessWidget { + const ReplayDock({super.key, required this.playback}); + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + constraints: const BoxConstraints(maxWidth: 880), + padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), + decoration: BoxDecoration( + color: theme.colorScheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: ListenableBuilder( + listenable: playback, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RoundStrip(playback: playback), + const SizedBox(height: 8), + Row( + children: [ + ShadIconButton.ghost( + width: 32, + height: 32, + onPressed: playback.togglePlaying, + icon: Icon( + playback.playing ? LucideIcons.pause : LucideIcons.play, + size: 18, + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 40, + child: Text( + _roundClock(playback), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: 8), + Expanded(child: _RoundScrubber(playback: playback)), + const SizedBox(width: 12), + _SpeedButton(playback: playback), + ], + ), + ], + ), + ), + ); + } + + /// Time since barriers dropped, counting into negative through the buy. + static String _roundClock(ReplayPlayback playback) { + final round = playback.round; + if (round == null) return replayClockLabel(playback.timeMs); + final sincePlay = playback.timeMs - round.playStartMs; + return sincePlay < 0 + ? '-${replayClockLabel(-sincePlay)}' + : replayClockLabel(sincePlay); + } +} + +/// One chip per round, filled with the winner's colour; a gap where the +/// teams swap sides. +class _RoundStrip extends StatelessWidget { + const _RoundStrip({required this.playback}); + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final rounds = playback.document.rounds; + final current = playback.round?.index; + final chips = []; + for (var i = 0; i < rounds.length; i++) { + final round = rounds[i]; + if (i > 0 && rounds[i - 1].attackingTeam != round.attackingTeam) { + chips.add(const SizedBox(width: 8)); + } + chips.add( + _RoundChip( + round: round, + perspective: playback.perspective, + selected: round.index == current, + onTap: () => playback.seekRound(round.index), + ), + ); + } + return SizedBox( + height: 22, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(spacing: 3, children: chips), + ), + ); + } +} + +class _RoundChip extends StatelessWidget { + const _RoundChip({ + required this.round, + required this.perspective, + required this.selected, + required this.onTap, + }); + + final ReplayRound round; + final ReplayTeam perspective; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final winner = round.winningTeam; + final fill = winner == null + ? theme.secondary + : winner == perspective + ? Settings.allyBGColor + : Settings.enemyBGColor; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 22, + height: 22, + alignment: Alignment.center, + decoration: selected + ? Settings.raised(fill, 4) + : BoxDecoration( + color: fill.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(4), + ), + foregroundDecoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Settings.accentInk, width: 1.5), + ) + : null, + child: Text( + '${round.index + 1}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: selected ? theme.foreground : theme.mutedForeground, + ), + ), + ), + ), + ); + } +} + +/// The round being watched, start to end. Drag or click to move through it; +/// ticks mark kills (in the killer's team colour) and the plant. +class _RoundScrubber extends StatelessWidget { + const _RoundScrubber({required this.playback}); + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final round = playback.round; + final start = round?.startMs ?? 0; + final end = round?.endMs ?? playback.durationMs; + final span = (end - start).clamp(1, 1 << 31); + + void seekTo(Offset local, double width) { + final fraction = (local.dx / width).clamp(0.0, 1.0); + playback.seek(start + (span * fraction).round()); + } + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => seekTo(details.localPosition, width), + onHorizontalDragUpdate: (details) => + seekTo(details.localPosition, width), + child: SizedBox( + height: 24, + child: CustomPaint( + painter: _ScrubberPainter( + playback: playback, + start: start, + span: span, + ), + ), + ), + ), + ); + }, + ); + } +} + +class _ScrubberPainter extends CustomPainter { + _ScrubberPainter({ + required this.playback, + required this.start, + required this.span, + }) : timeMs = playback.timeMs, + perspective = playback.perspective; + + final ReplayPlayback playback; + final int start; + final int span; + final int timeMs; + final ReplayTeam perspective; + + @override + void paint(Canvas canvas, Size size) { + const theme = Settings.tacticalVioletTheme; + final midY = size.height / 2; + double x(int ms) => ((ms - start) / span).clamp(0.0, 1.0) * size.width; + + final track = RRect.fromLTRBR( + 0, + midY - 2, + size.width, + midY + 2, + const Radius.circular(2), + ); + canvas.drawRRect(track, Paint()..color = theme.secondary); + canvas.drawRRect( + RRect.fromLTRBR( + 0, midY - 2, x(timeMs), midY + 2, const Radius.circular(2)), + Paint()..color = theme.mutedForeground, + ); + + final round = playback.round; + if (round != null) { + final play = x(round.playStartMs); + canvas.drawLine( + Offset(play, midY - 6), + Offset(play, midY + 6), + Paint() + ..color = theme.mutedForeground + ..strokeWidth = 1, + ); + for (final kill in playback.document.kills) { + if (!round.contains(kill.timeMs)) continue; + final killer = playback.document.playerBySubject(kill.killer); + final victim = playback.document.playerBySubject(kill.victim); + // A kill counts for the killer's team; a fall or spike death for + // the other team. + final scoringTeam = killer?.team ?? victim?.team.other; + final color = scoringTeam == null + ? theme.mutedForeground + : scoringTeam == playback.perspective + ? Settings.allyInk + : Settings.enemyInk; + canvas.drawRRect( + RRect.fromLTRBR( + x(kill.timeMs) - 1.5, + midY - 7, + x(kill.timeMs) + 1.5, + midY + 7, + const Radius.circular(1.5), + ), + Paint()..color = color, + ); + } + final plant = round.plant; + if (plant != null) { + final px = x(plant.timeMs); + final path = Path() + ..moveTo(px, midY - 8) + ..lineTo(px + 5, midY) + ..lineTo(px, midY + 8) + ..lineTo(px - 5, midY) + ..close(); + canvas.drawPath(path, Paint()..color = theme.foreground); + } + } + + canvas.drawCircle( + Offset(x(timeMs), midY), + 6, + Paint()..color = theme.foreground, + ); + } + + @override + bool shouldRepaint(_ScrubberPainter old) => + old.timeMs != timeMs || + old.start != start || + old.span != span || + old.perspective != perspective; +} + +class _SpeedButton extends StatelessWidget { + const _SpeedButton({required this.playback}); + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final speeds = ReplayPlayback.speeds; + final next = speeds[(speeds.indexOf(playback.speed) + 1) % speeds.length]; + final label = playback.speed == playback.speed.roundToDouble() + ? '${playback.speed.round()}×' + : '${playback.speed}×'; + return ShadTooltip( + builder: (context) => const Text('Playback speed'), + child: ShadButton.outline( + height: 28, + width: 44, + padding: EdgeInsets.zero, + onPressed: () => playback.speed = next, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ); + } +} diff --git a/lib/widgets/replay/replay_library_content.dart b/lib/widgets/replay/replay_library_content.dart new file mode 100644 index 000000000..eb152f3a4 --- /dev/null +++ b/lib/widgets/replay/replay_library_content.dart @@ -0,0 +1,450 @@ +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; +import 'package:icarus/replay/replay_agents.dart'; +import 'package:icarus/replay/replay_files.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay_view.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/widgets/hover_dot_grid.dart'; +import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; +import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; +import 'package:path/path.dart' as p; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// The Replays tab: every match Valorant downloaded plus the ones brought into +/// Icarus, newest first, on the same grid as strategies. +class ReplayLibraryContent extends ConsumerWidget { + const ReplayLibraryContent({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final listings = ref.watch(replayLibraryProvider); + return ReplayDropTarget( + child: Stack( + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4), + child: HoverDotGrid(), + ), + ), + Positioned.fill( + child: listings.when( + loading: () => const SizedBox.shrink(), + error: (error, _) => _Message( + title: "Couldn't list replays", + hint: '$error', + ), + data: (listings) => listings.isEmpty + ? const _Message( + title: 'No replays yet', + hint: 'Download a match from your career page in ' + 'Valorant, or drop a .vrf file here', + ) + : _ReplayGrid(listings: listings), + ), + ), + ], + ), + ); + } +} + +class _ReplayGrid extends StatelessWidget { + const _ReplayGrid({required this.listings}); + + final List listings; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // The strategy grid's columns, so the two tabs line up. + const minTileWidth = 250.0; + const padding = 32.0; + final columns = + ((constraints.maxWidth - padding + strategyTileGridSpacing) / + (minTileWidth + strategyTileGridSpacing)) + .floor() + .clamp(1, 1 << 20); + return GridView.builder( + padding: const EdgeInsets.all(16 - strategyTileGutterOutset), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: strategyTileGridMainAxisExtent, + ), + itemCount: listings.length, + itemBuilder: (context, index) => ReplayTile( + key: ValueKey(listings[index].file.path), + listing: listings[index], + ), + ); + }, + ); + } +} + +/// One replay on the grid. Built like a strategy tile: map art above, the +/// match below. +class ReplayTile extends ConsumerStatefulWidget { + const ReplayTile({super.key, required this.listing}); + + final ReplayListing listing; + + @override + ConsumerState createState() => _ReplayTileState(); +} + +class _ReplayTileState extends ConsumerState { + static const _ringWidth = 2.0; + bool _hovered = false; + + void _open() { + final listing = widget.listing; + final probe = listing.probe; + if (probe == null || !probe.supported) { + Settings.showToast( + message: _unavailableReason(listing), + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + Navigator.push(context, ReplayView.route(file: listing.file, probe: probe)); + } + + Future _keep() async { + try { + await ref.read(replayLibraryProvider.notifier).import( + widget.listing.file.path, + ); + Settings.showToast( + message: 'Kept in Icarus. Valorant can no longer remove it.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + "Couldn't keep this replay.", + error: error, + stackTrace: stackTrace, + source: 'ReplayTile.keep', + ); + } + } + + @override + Widget build(BuildContext context) { + final listing = widget.listing; + final probe = listing.probe; + final map = probe == null + ? null + : ReplayMapProjection.forMapPath(probe.mapPath)?.map; + final available = listing.canOpen && map != null; + const theme = Settings.tacticalVioletTheme; + + return Padding( + padding: const EdgeInsets.all(strategyTileGutterOutset), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: ShadContextMenuRegion( + items: [ + if (listing.file.source == ReplaySource.valorant) + ShadContextMenuItem( + leading: const Icon(LucideIcons.archive), + onPressed: _keep, + child: const Text('Keep in Icarus'), + ), + ], + child: GestureDetector( + onTap: _open, + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + decoration: BoxDecoration( + color: theme.border, + gradient: + _hovered && available ? Settings.raisedPrimaryFill : null, + borderRadius: BorderRadius.circular(strategyTileOuterRadius), + ), + padding: const EdgeInsets.all(_ringWidth), + child: Container( + decoration: BoxDecoration( + color: theme.card, + borderRadius: BorderRadius.circular( + strategyTileOuterRadius - _ringWidth, + ), + ), + padding: const EdgeInsets.all(8 - _ringWidth), + child: Opacity( + opacity: available ? 1 : 0.5, + child: Column( + children: [ + Expanded( + child: map == null + ? DecoratedBox( + decoration: BoxDecoration( + color: theme.secondary, + borderRadius: BorderRadius.circular( + strategyTileInnerRadius, + ), + ), + child: const SizedBox.expand(), + ) + : StrategyTileThumbnail( + assetPath: 'assets/maps/thumbnails/' + '${Maps.mapNames[map]}_thumbnail.webp', + borderRadius: strategyTileInnerRadius, + ), + ), + const SizedBox(height: 10), + Expanded( + child: _ReplayDetails(listing: listing, map: map)), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +String _unavailableReason(ReplayListing listing) { + final probe = listing.probe; + if (probe == null) { + return listing.error?.message ?? "This file isn't a Valorant replay."; + } + if (!probe.supported) { + return 'Replays from patch ${replayPatchLabel(probe.build)} ' + "can't be read yet. An Icarus update will add them."; + } + return "This replay's map isn't in Icarus yet."; +} + +/// `++Ares-Core+release-13.06` reads as `13.06`. +String replayPatchLabel(String build) { + final match = RegExp(r'release-([0-9]+\.[0-9]+)').firstMatch(build); + return match?.group(1) ?? build; +} + +String replayDurationLabel(int durationMs) { + final minutes = durationMs ~/ 60000; + final seconds = (durationMs ~/ 1000) % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; +} + +class _ReplayDetails extends StatelessWidget { + const _ReplayDetails({required this.listing, required this.map}); + + final ReplayListing listing; + final MapValue? map; + + @override + Widget build(BuildContext context) { + final probe = listing.probe; + final theme = ShadTheme.of(context); + final mapName = map == null + ? (probe == null ? 'Unreadable file' : 'Unknown map') + : Maps.mapNames[map]!.replaceRange( + 0, + 1, + Maps.mapNames[map]![0].toUpperCase(), + ); + final date = probe?.recordedAt ?? listing.file.modified; + final status = probe == null || !listing.canOpen + ? (probe == null + ? 'Not a replay' + : 'Patch ${replayPatchLabel(probe.build)}') + : replayDurationLabel(probe.durationMs); + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.card, + borderRadius: BorderRadius.circular(strategyTileInnerRadius), + border: Border.all(color: Settings.tacticalVioletTheme.border), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + mapName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Text(status, style: theme.textTheme.muted), + ], + ), + const SizedBox(height: 5), + Text( + MaterialLocalizations.of(context).formatMediumDate(date), + style: theme.textTheme.muted, + ), + const Spacer(), + if (probe != null) _AgentStrip(agentIds: probe.agentIds), + ], + ), + ); + } +} + +/// The ten agents in the match, small enough to fit one row. +class _AgentStrip extends StatelessWidget { + const _AgentStrip({required this.agentIds}); + + final List agentIds; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + const gap = 3.0; + final count = agentIds.length.clamp(1, 10); + final size = ((constraints.maxWidth - gap * (count - 1)) / count) + .clamp(12.0, 24.0); + return Row( + spacing: gap, + children: [ + for (final id in agentIds.take(10)) + _AgentChip(agent: replayAgentType(id), size: size), + ], + ); + }, + ); + } +} + +class _AgentChip extends StatelessWidget { + const _AgentChip({required this.agent, required this.size}); + + final AgentType? agent; + final double size; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: theme.card, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: theme.border), + ), + child: + agent == null ? null : Image.asset(AgentData.agents[agent]!.iconPath), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({required this.title, required this.hint}); + + final String title; + final String hint; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: theme.textTheme.p), + const SizedBox(height: 4), + Text(hint, style: theme.textTheme.muted, textAlign: TextAlign.center), + ], + ), + ); + } +} + +/// Takes .vrf files dropped on the Replays tab into Icarus's replays folder. +class ReplayDropTarget extends ConsumerStatefulWidget { + const ReplayDropTarget({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _ReplayDropTargetState(); +} + +class _ReplayDropTargetState extends ConsumerState { + bool _dragging = false; + + Future _import(List paths) async { + final replays = [ + for (final path in paths) + if (p.extension(path).toLowerCase() == ReplayFiles.extension) path, + ]; + if (replays.isEmpty) { + Settings.showToast( + message: 'Only Valorant replays (.vrf) can be dropped here.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + for (final path in replays) { + try { + await ref.read(replayLibraryProvider.notifier).import(path); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + "Couldn't import ${p.basename(path)}.", + error: error, + stackTrace: stackTrace, + source: 'ReplayDropTarget', + ); + } + } + } + + @override + Widget build(BuildContext context) { + return DropTarget( + onDragEntered: (_) => setState(() => _dragging = true), + onDragExited: (_) => setState(() => _dragging = false), + onDragDone: (details) { + setState(() => _dragging = false); + _import([for (final file in details.files) file.path]); + }, + child: Stack( + children: [ + Positioned.fill(child: widget.child), + if (_dragging) ...[ + const Positioned.fill( + child: ColoredBox(color: Color.fromARGB(118, 2, 2, 2)), + ), + const Positioned.fill( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.download, size: 60), + SizedBox(height: 10), + Text( + 'Add replays', + style: + TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart new file mode 100644 index 000000000..a649a2a4b --- /dev/null +++ b/lib/widgets/replay/replay_match_card.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/replay/replay_capture.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/replay_view.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy_view.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Where the match stands, and the one thing to do with it: capture the +/// moment into a strategy. +class ReplayMatchCard extends ConsumerStatefulWidget { + const ReplayMatchCard({super.key, required this.playback, required this.map}); + + final ReplayPlayback playback; + final MapValue map; + + @override + ConsumerState createState() => _ReplayMatchCardState(); +} + +class _ReplayMatchCardState extends ConsumerState { + late final ReplayCapture _capture = ReplayCapture( + ref: ref, + map: widget.map, + name: '${replayMapName(widget.map)} replay · ' + '${_dateLabel(widget.playback.document.match.recordedAt)}', + ); + bool _capturing = false; + int _captured = 0; + + String _dateLabel(DateTime? date) => + MaterialLocalizations.of(context).formatShortDate(date ?? DateTime.now()); + + Future _captureMoment() async { + if (_capturing) return; + final playback = widget.playback; + playback.pause(); + setState(() => _capturing = true); + try { + // A fresh frame: the saved page must not share objects with the one + // on screen. + final frame = playback.frames.frameAt( + playback.timeMs, + perspective: playback.perspective, + ); + await _capture.capture(frame, pageName: replayMomentLabel(playback)); + setState(() => _captured++); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + "Couldn't capture this moment.", + error: error, + stackTrace: stackTrace, + source: 'ReplayMatchCard.capture', + ); + } finally { + if (mounted) setState(() => _capturing = false); + } + } + + void _openStrategy() { + final strategy = _capture.strategy; + if (strategy == null) return; + widget.playback.pause(); + Navigator.push( + context, + StrategyView.route( + initialStrategyId: strategy.id, + initialStrategyName: strategy.name, + initialMapValue: strategy.mapData, + initialIsAttack: strategy.pages.last.isAttack, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + width: 260, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: ListenableBuilder( + listenable: widget.playback, + builder: (context, _) { + final playback = widget.playback; + final round = playback.round; + final score = _score(playback); + final isAttack = playback.frame.isAttack; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + round == null ? 'Warmup' : 'Round ${round.index + 1}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Text( + isAttack ? 'Attack' : 'Defense', + style: theme.textTheme.muted, + ), + const Spacer(), + _Score(ally: score.ally, enemy: score.enemy), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ShadButton( + height: 32, + onPressed: _capturing ? null : _captureMoment, + leading: const Icon(LucideIcons.camera, size: 16), + child: const Text('Capture'), + ), + ), + const SizedBox(width: 8), + ShadTooltip( + builder: (context) => + const Text("See the other team's side"), + child: ShadIconButton.outline( + width: 32, + height: 32, + onPressed: () => + playback.perspective = playback.perspective.other, + icon: const Icon(LucideIcons.arrowLeftRight, size: 16), + ), + ), + ], + ), + if (_captured > 0) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + _captured == 1 + ? '1 page captured' + : '$_captured pages captured', + style: theme.textTheme.muted, + ), + ), + ShadButton.link( + height: 24, + padding: EdgeInsets.zero, + onPressed: _openStrategy, + child: const Text('Open'), + ), + ], + ), + ], + ], + ); + }, + ), + ); + } + + /// Rounds won before the one being watched. + ({int ally, int enemy}) _score(ReplayPlayback playback) { + final current = playback.round?.index ?? 0; + var ally = 0; + var enemy = 0; + for (final round in playback.document.rounds) { + if (round.index >= current) break; + final winner = round.winningTeam; + if (winner == null) continue; + if (winner == playback.perspective) { + ally++; + } else { + enemy++; + } + } + return (ally: ally, enemy: enemy); + } +} + +class _Score extends StatelessWidget { + const _Score({required this.ally, required this.enemy}); + + final int ally; + final int enemy; + + @override + Widget build(BuildContext context) { + const style = TextStyle(fontSize: 16, fontWeight: FontWeight.w600); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$ally', style: style.copyWith(color: Settings.allyInk)), + Text(' – ', + style: style.copyWith( + color: Settings.tacticalVioletTheme.mutedForeground)), + Text('$enemy', style: style.copyWith(color: Settings.enemyInk)), + ], + ); + } +} + +/// "Round 7 · 0:42", the time since barriers dropped; "Round 7 · Buy" before. +String replayMomentLabel(ReplayPlayback playback) { + final round = playback.round; + if (round == null) return replayClockLabel(playback.timeMs); + final sincePlay = playback.timeMs - round.playStartMs; + return 'Round ${round.index + 1} · ' + '${sincePlay < 0 ? 'Buy' : replayClockLabel(sincePlay)}'; +} + +String replayClockLabel(int ms) { + final seconds = (ms / 1000).floor().clamp(0, 1 << 30); + return '${seconds ~/ 60}:${(seconds % 60).toString().padLeft(2, '0')}'; +} + +extension ReplayTeamColors on ReplayTeam { + Color colorFor(ReplayTeam perspective) => + this == perspective ? Settings.allyInk : Settings.enemyInk; +} diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart new file mode 100644 index 000000000..9f8bd0739 --- /dev/null +++ b/lib/widgets/replay/replay_roster.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/replay/replay_agents.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/replay/replay_weapons.dart'; +import 'package:icarus/widgets/replay/replay_match_card.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Both teams as they stand right now, then the round's kills. +class ReplayRoster extends StatelessWidget { + const ReplayRoster({super.key, required this.playback}); + + static const double width = 280; + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + width: width, + decoration: BoxDecoration( + color: theme.colorScheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: ListenableBuilder( + listenable: playback, + builder: (context, _) { + final perspective = playback.perspective; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TeamBlock( + playback: playback, + team: perspective, + label: 'Allies', + ), + Divider(height: 1, color: theme.colorScheme.border), + _TeamBlock( + playback: playback, + team: perspective.other, + label: 'Enemies', + ), + Divider(height: 1, color: theme.colorScheme.border), + Expanded(child: _Killfeed(playback: playback)), + ], + ); + }, + ), + ); + } +} + +class _TeamBlock extends StatelessWidget { + const _TeamBlock({ + required this.playback, + required this.team, + required this.label, + }); + + final ReplayPlayback playback; + final ReplayTeam team; + final String label; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final states = [ + for (final player in playback.document.players) + if (player.team == team) + playback.frames.playerState(player, playback.timeMs), + ]; + final alive = states.where((state) => state.alive).length; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: team.colorFor(playback.perspective), + ), + ), + const Spacer(), + Text('$alive alive', style: theme.textTheme.muted), + ], + ), + const SizedBox(height: 6), + for (final state in states) + _PlayerRow( + state: state, + economy: playback.round?.economyFor(state.player.subject), + ), + ], + ), + ); + } +} + +class _PlayerRow extends StatelessWidget { + const _PlayerRow({required this.state, required this.economy}); + + final ReplayPlayerState state; + final ReplayEconomy? economy; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final agent = replayAgentType(state.player.agentId); + final agentData = agent == null ? null : AgentData.agents[agent]; + final weapon = replayWeaponType(economy?.weapon); + final name = state.player.name ?? agentData?.name ?? 'Unknown agent'; + return Opacity( + opacity: state.alive ? 1 : 0.4, + child: SizedBox( + height: 36, + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + borderRadius: BorderRadius.circular(6), + ), + child: agentData == null ? null : Image.asset(agentData.iconPath), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 4), + _VitalsBar(health: state.health, armor: state.armor), + ], + ), + ), + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (weapon != WeaponType.none) + Image.asset(weapon.iconPath, height: 14) + else + const SizedBox(height: 14), + if (economy != null) + Text( + '${economy!.credits}', + style: theme.textTheme.muted.copyWith(fontSize: 11), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Health as a filled bar out of 100, armor as a thinner bar under it. +class _VitalsBar extends StatelessWidget { + const _VitalsBar({required this.health, required this.armor}); + + final double? health; + final double? armor; + + @override + Widget build(BuildContext context) { + final track = Settings.tacticalVioletTheme.secondary; + Widget bar(double fraction, Color color, double height) => ClipRRect( + borderRadius: BorderRadius.circular(height), + child: SizedBox( + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: track), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: fraction.clamp(0.0, 1.0), + child: ColoredBox(color: color), + ), + ], + ), + ), + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + bar((health ?? 100) / 100, Settings.tacticalVioletTheme.foreground, 3), + const SizedBox(height: 2), + bar((armor ?? 0) / 50, Settings.tacticalVioletTheme.mutedForeground, 2), + ], + ); + } +} + +/// This round's kills so far, newest first. Clicking one replays it. +class _Killfeed extends StatelessWidget { + const _Killfeed({required this.playback}); + + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final round = playback.round; + final now = playback.timeMs; + final kills = [ + for (final kill in playback.document.kills) + if (round != null && kill.timeMs >= round.startMs && kill.timeMs <= now) + kill, + ].reversed.toList(); + if (kills.isEmpty) { + return Center( + child: Text('No kills yet this round', style: theme.textTheme.muted), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: kills.length, + itemBuilder: (context, index) => _KillRow( + kill: kills[index], + playback: playback, + ), + ); + } +} + +class _KillRow extends StatelessWidget { + const _KillRow({required this.kill, required this.playback}); + + final ReplayKill kill; + final ReplayPlayback playback; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final document = playback.document; + final killer = document.playerBySubject(kill.killer); + final victim = document.playerBySubject(kill.victim); + final round = playback.round; + final since = round == null ? kill.timeMs : kill.timeMs - round.playStartMs; + return ShadButton.ghost( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 12), + mainAxisAlignment: MainAxisAlignment.start, + onPressed: () => playback.seek(kill.timeMs - 2000), + child: Row( + children: [ + SizedBox( + width: 36, + child: Text( + replayClockLabel(since), + style: theme.textTheme.muted.copyWith(fontSize: 11), + ), + ), + _KillIcon(player: killer, perspective: playback.perspective), + const SizedBox(width: 6), + Icon( + LucideIcons.crosshair, + size: 12, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 6), + _KillIcon(player: victim, perspective: playback.perspective), + ], + ), + ); + } +} + +class _KillIcon extends StatelessWidget { + const _KillIcon({required this.player, required this.perspective}); + + final ReplayPlayer? player; + final ReplayTeam perspective; + + @override + Widget build(BuildContext context) { + final agent = player == null ? null : replayAgentType(player!.agentId); + return Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.secondary, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: player == null + ? Settings.tacticalVioletTheme.border + : player!.team.colorFor(perspective), + ), + ), + child: + agent == null ? null : Image.asset(AgentData.agents[agent]!.iconPath), + ); + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 80a798de7..d53e05a57 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -246,6 +246,7 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, ACF08380D4524C41B9367A01 /* Build standing visibility */, + 5D1C2E7A9B3F4A60B8E1C042 /* Build replay decoder */, 3399D490228B24CF009A79C7 /* ShellScript */, F4B940F9442DB0FF89EE4EA2 /* [CP] Embed Pods Frameworks */, ); @@ -348,6 +349,20 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$SRCROOT/../native/height/build_macos.sh\"\n"; }; + 5D1C2E7A9B3F4A60B8E1C042 /* Build replay decoder */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = (); + inputPaths = ( + "$(SRCROOT)/../native/replay/build_macos.sh", + ); + name = "Build replay decoder"; + outputPaths = ("$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/libicarus_replay.dylib"); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$SRCROOT/../native/replay/build_macos.sh\"\n"; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/native/replay/.gitignore b/native/replay/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/native/replay/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/native/replay/CMakeLists.txt b/native/replay/CMakeLists.txt new file mode 100644 index 000000000..ba07bb1ae --- /dev/null +++ b/native/replay/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) +project(icarus_replay NONE) + +# The replay decoder is a Rust crate. Cargo builds it in release (a debug +# decode is several times slower) into its own target directory under this +# build tree; it is incremental, so an unchanged crate costs a second. +find_program(CARGO cargo + HINTS "$ENV{CARGO_HOME}/bin" "$ENV{USERPROFILE}/.cargo/bin" "$ENV{HOME}/.cargo/bin" + REQUIRED) + +set(ICARUS_REPLAY_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargo") +if(WIN32) + set(ICARUS_REPLAY_LIBRARY "${ICARUS_REPLAY_TARGET_DIR}/release/icarus_replay.dll") +else() + set(ICARUS_REPLAY_LIBRARY "${ICARUS_REPLAY_TARGET_DIR}/release/libicarus_replay.so") +endif() +set(ICARUS_REPLAY_LIBRARY "${ICARUS_REPLAY_LIBRARY}" PARENT_SCOPE) + +add_custom_target(icarus_replay ALL + COMMAND "${CARGO}" build --release --locked --lib + --manifest-path "${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml" + --target-dir "${ICARUS_REPLAY_TARGET_DIR}" + BYPRODUCTS "${ICARUS_REPLAY_LIBRARY}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Building the replay decoder (cargo)" + USES_TERMINAL + VERBATIM) diff --git a/native/replay/Cargo.lock b/native/replay/Cargo.lock new file mode 100644 index 000000000..3d05f19ac --- /dev/null +++ b/native/replay/Cargo.lock @@ -0,0 +1,313 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "icarus_replay" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "vrf-bitio", + "vrf-container", + "vrf-decode", + "vrf-export", + "vrf-frame", + "vrf-movement", + "vrf-net", + "vrf-schema", + "vrf-testkit", + "vrf-transform", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vrf-bitio" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" + +[[package]] +name = "vrf-container" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", +] + +[[package]] +name = "vrf-decode" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", +] + +[[package]] +name = "vrf-export" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "smallvec", + "thiserror", +] + +[[package]] +name = "vrf-frame" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", + "vrf-schema", +] + +[[package]] +name = "vrf-movement" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", +] + +[[package]] +name = "vrf-net" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", + "vrf-schema", + "vrf-transform", +] + +[[package]] +name = "vrf-schema" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "thiserror", + "vrf-bitio", +] + +[[package]] +name = "vrf-testkit" +version = "0.0.0" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" + +[[package]] +name = "vrf-transform" +version = "0.2.5" +source = "git+https://github.com/yakisoba0728/vrfkit?tag=v0.2.5#c4d6cc0e5ec3904fdb64b350b2656b8e3ff33d53" +dependencies = [ + "vrf-bitio", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/replay/Cargo.toml b/native/replay/Cargo.toml new file mode 100644 index 000000000..451a03d28 --- /dev/null +++ b/native/replay/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "icarus_replay" +version = "0.1.0" +edition = "2024" +rust-version = "1.86" +license = "Apache-2.0" +publish = false +description = "Decodes Valorant .vrf replays into Icarus's decoded replay buffer (docs/replay-format.md)" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Dev-only Oodle stand-in: looks archives up by sha256 in $ICARUS_OODLE_VECTORS. +# Never on by default; a production build without a decoder reports +# `unsupportedCompression`. +dev-vectors = ["dep:sha2"] + +[[bin]] +name = "icarus-replay-dump" +path = "src/bin/icarus-replay-dump.rs" + +# vrfkit v0.2.5 (c4d6cc0). `vrf-container` keeps its `oodle` feature off so the +# GPL-derived oozextract never enters the build; decompression is ours (src/oodle.rs). +[dependencies] +vrf-bitio = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-container = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5", default-features = false, features = ["event"] } +vrf-schema = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5", default-features = false } +vrf-net = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-frame = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-movement = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-decode = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-transform = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } +vrf-export = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5", default-features = false } +smallvec = { version = "1", features = ["union"] } +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { version = "0.10", optional = true } + +[dev-dependencies] +vrf-testkit = { git = "https://github.com/yakisoba0728/vrfkit", tag = "v0.2.5" } + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "unwind" + +# Corpus tests decode whole replays. +[profile.test] +opt-level = 2 + +[profile.dev.package."*"] +opt-level = 2 diff --git a/native/replay/NOTICE.md b/native/replay/NOTICE.md new file mode 100644 index 000000000..65900e39f --- /dev/null +++ b/native/replay/NOTICE.md @@ -0,0 +1,81 @@ +# Notices + +`native/replay` depends on the vrfkit library crates +(, tag v0.2.5, commit c4d6cc0) and +vendors code from its CLI crate into `src/vendor/` (each file names its +source). vrfkit's NOTICE.md follows verbatim; the full Apache License 2.0 text +is the repository's `LICENSE`, the same as vrfkit's. + +--- + +# vrfkit + +Copyright 2026 vrfkit contributors + +Licensed under the Apache License, Version 2.0; see [`LICENSE`](LICENSE). + +## Earlier MIT-licensed releases + +Releases up to and including v0.2.0 were published under the MIT License, and +contributions made before the change to Apache-2.0 were made under it. The MIT +License allows that code to be distributed under other terms as long as its +notice stays with it, so the notice is kept here: + +``` +MIT License + +Copyright (c) 2026 vrfkit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +# Third-party notices + +Parts of vrfkit are derived from ValorantReplayParser, used under the MIT +License: + +``` +MIT License + +Copyright (c) 2026 Michel Giehl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +# Disclaimer + +This project is an independent, community-developed tool and is not affiliated +with, endorsed by, sponsored by, or approved by Riot Games. VALORANT, Riot Games, +and all related trademarks are the property of Riot Games, Inc. diff --git a/native/replay/build_macos.sh b/native/replay/build_macos.sh new file mode 100644 index 000000000..7fd5e359c --- /dev/null +++ b/native/replay/build_macos.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Xcode build phase: builds libicarus_replay.dylib for every arch in $ARCHS +# (lipo'd into one universal dylib) into the app's Frameworks folder. +# Needs rustup with the aarch64-apple-darwin and x86_64-apple-darwin targets. +set -eu + +source_dir="$SRCROOT/../native/replay" +output_dir="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH" +target_dir="$TARGET_TEMP_DIR/icarus_replay_cargo" +cargo="${CARGO:-$HOME/.cargo/bin/cargo}" +if [ ! -x "$cargo" ]; then + cargo="$(command -v cargo)" +fi +mkdir -p "$output_dir" + +# Rust reads MACOSX_DEPLOYMENT_TARGET from the environment Xcode sets; the +# install name is set at link time so nothing edits (and unsigns) the dylib. +export RUSTFLAGS="-C link-arg=-Wl,-install_name,@rpath/libicarus_replay.dylib" + +set -- +for architecture in $ARCHS; do + case "$architecture" in + arm64) triple=aarch64-apple-darwin ;; + x86_64) triple=x86_64-apple-darwin ;; + *) echo "error: no Rust target for $architecture" >&2; exit 1 ;; + esac + "$cargo" build --release --locked --lib \ + --manifest-path "$source_dir/Cargo.toml" \ + --target "$triple" --target-dir "$target_dir" + set -- "$@" "$target_dir/$triple/release/libicarus_replay.dylib" +done +xcrun lipo -create "$@" -output "$output_dir/libicarus_replay.dylib" +if [ "${CODE_SIGNING_ALLOWED:-NO}" = YES ]; then + codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" \ + --timestamp=none "$output_dir/libicarus_replay.dylib" +fi diff --git a/native/replay/include/icarus_replay.h b/native/replay/include/icarus_replay.h new file mode 100644 index 000000000..074e9f59c --- /dev/null +++ b/native/replay/include/icarus_replay.h @@ -0,0 +1,58 @@ +/* + * icarus_replay: decodes a Valorant .vrf replay into the buffer described in + * docs/replay-format.md. Three functions; every buffer they return is owned + * by the library until passed to icarus_replay_free, exactly once. + * + * When is_error is nonzero, ptr holds a UTF-8 JSON error object instead: + * { "code": "...", "message": "...", "build"?: "..." } + * with code one of notAReplay, unsupportedBuild, unsupportedCompression, + * transformCheckFailed, corrupt, io, cancelled. + * + * Paths are NUL-terminated UTF-8. The functions are safe to call from any + * thread; nothing is shared between calls. + */ +#ifndef ICARUS_REPLAY_H +#define ICARUS_REPLAY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint8_t* ptr; + size_t len; + uint32_t is_error; +} IcarusReplayBuffer; + +/* + * Header-only probe for a replay list: JSON + * { "id", "mapPath", "build", "durationMs", "recordedAt"?, "supported", + * "decoderVersion", "players": [{ "subject", "agentId" }] } + * Reads the replay info and Header chunk only; never decompresses. + */ +IcarusReplayBuffer icarus_replay_probe(const char* path_utf8); + +/* + * Full decode: the .icrp buffer, or an error object. No callbacks; the + * caller owns two cells it polls/sets from another thread: + * progress written by the decoder with 0..10000 (hundredths of a percent), + * relaxed atomic stores; + * cancel read between chunks and packets; nonzero stops the decode with + * the `cancelled` error. + * Either pointer may be NULL. Both must be 4-byte aligned, stay valid until + * the call returns, and be accessed atomically by other threads. + */ +IcarusReplayBuffer icarus_replay_decode(const char* path_utf8, uint32_t* progress, + const uint32_t* cancel); + +/* Releases a buffer from either function. A NULL ptr is ignored. */ +void icarus_replay_free(IcarusReplayBuffer buffer); + +#ifdef __cplusplus +} +#endif + +#endif /* ICARUS_REPLAY_H */ diff --git a/native/replay/src/analysis/casts.rs b/native/replay/src/analysis/casts.rs new file mode 100644 index 000000000..18434efa7 --- /dev/null +++ b/native/replay/src/analysis/casts.rs @@ -0,0 +1,221 @@ +//! Ability casts from `Comp_AbilityStatisticsReplicator.AbilityCastsThisRound` +//! (docs/DATA.md, "Abilities"): `Player`, `Slot`, `Round`, `CastTime`, +//! `CastLocation`. The array accumulates over a round and is re-sent, so a +//! cast is one identity (player, round, slot, cast time), counted once. +//! `CastTime` counts from the round's buy-phase end, so a cast happened at +//! `combatStartMs + CastTime` ("`CastTime` is not measured from roundStarted"). +//! +//! The array names a slot, not an ability. A cast lands within a few +//! milliseconds of the charge (`AuthResourceAmount`) its ability item spends +//! (0-3 ms on 1fb53a2c), so each (player, slot) takes the item class those +//! decrements agree on. +//! Where they do not agree, or never fire (an ultimate spends no charge), +//! `classPath` stays absent. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use super::{Context, parse_vector, value_at}; +use crate::document::{Cast, Round}; +use crate::fieldpath::FieldPath; + +/// An array element: (component actor, component object, index). +type ElementKey = (u32, Option, u32); + +/// An element's state after one packet's writes to it. +struct Snapshot { + time: u32, + packet: u32, + key: ElementKey, + state: Element, +} + +#[derive(Default, Clone)] +struct Element { + player: Option, + slot: Option, + round: Option, + cast_time: Option, + location: Option, +} + +pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { + // Replicated state per array element, and its state after each packet + // that wrote to it. + let mut elements: HashMap = HashMap::new(); + let mut snapshots: Vec = Vec::new(); + for row in cx.rows { + if !row.name.starts_with("AbilityCastsThisRound[") { + continue; + } + let path = FieldPath::parse(&row.name); + let Some(index) = path.segments[0].index else { + continue; + }; + let key = (row.actor, row.object, index); + let e = elements.entry(key).or_default(); + match path.leaf() { + "Player" => e.player = row.text.as_ref().map(|s| s.to_ascii_lowercase()), + "Slot" => e.slot = row.int, + "Round" => e.round = row.int, + "CastTime" => e.cast_time = row.float, + "CastLocation" => e.location = row.text.clone(), + _ => continue, + } + let state = e.clone(); + match snapshots.last_mut() { + Some(last) if last.packet == row.packet && last.key == key => last.state = state, + _ => snapshots.push(Snapshot { + time: row.time, + packet: row.packet, + key, + state, + }), + } + } + + // Identities in first-send order. + let mut seen: HashSet<(String, i64, i64, u64)> = HashSet::new(); + let mut found: Vec<(u32, Element)> = Vec::new(); + for Snapshot { + time, + state: element, + .. + } in snapshots + { + let (Some(player), Some(slot), Some(round), Some(cast_time)) = ( + element.player.clone(), + element.slot, + element.round, + element.cast_time, + ) else { + continue; + }; + if seen.insert((player, round, slot, cast_time.to_bits())) { + found.push((time, element)); + } + } + + // `Round` is the game's round number; rounds warn where it is not their index. + let buy_end: HashMap = rounds + .iter() + .filter_map(|r| Some((i64::from(r.index), r.combat_start_ms?))) + .collect(); + let mut unplaced = 0; + let timed: Vec<(i64, Element)> = found + .into_iter() + .map(|(first_send, e)| { + let cast_ms = e + .round + .and_then(|r| buy_end.get(&r)) + .map(|&b| b + (e.cast_time.expect("identity has a time") * 1000.0).round() as i64); + if cast_ms.is_none() { + unplaced += 1; + } + (cast_ms.unwrap_or(i64::from(first_send)), e) + }) + .collect(); + let classes = slot_classes(cx, &timed); + let mut casts: Vec = timed + .into_iter() + .map(|(time_ms, e)| { + let player = e.player.expect("identity has a player"); + let slot = e.slot.expect("identity has a slot"); + Cast { + time_ms, + class_path: classes.get(&(player.clone(), slot)).cloned(), + subject: player, + slot, + position: e.location.as_deref().and_then(parse_vector), + } + }) + .collect(); + casts.sort_by(|a, b| (a.time_ms, &a.subject).cmp(&(b.time_ms, &b.subject))); + if unplaced > 0 { + cx.warnings.push(format!( + "{unplaced} cast(s) in a round with no buy-phase end; timed by first send" + )); + } + let unnamed = casts.iter().filter(|c| c.class_path.is_none()).count(); + if unnamed > 0 { + cx.warnings.push(format!( + "{unnamed} of {} cast(s) without an ability class", + casts.len() + )); + } + casts +} + +/// (player, slot) -> ability item class, where the charge decrements in each +/// cast's first-send packet name one class without dissent, and no other +/// slot of that player names it too (Reyna's two abilities spend one pool). +/// Only ability items count: a gun's ammo is an `AuthResourceAmount` too. +fn slot_classes(cx: &Context<'_>, casts: &[(i64, Element)]) -> HashMap<(String, i64), String> { + /// Spends within this of the cast belong to it. + const WINDOW_MS: i64 = 10; + // Charge decrements: (time, ability actor). + let mut last: HashMap<(u32, Option), i64> = HashMap::new(); + let mut spends: Vec<(i64, u32)> = Vec::new(); + for row in cx.rows.iter().filter(|r| { + &*r.name == "AuthResourceAmount" + && (r.group.ends_with("EquipmentChargeComponent") + || r.group.ends_with("SignatureAbilityResourceComponent")) + }) { + let Some(v) = row.int else { continue }; + if let Some(before) = last.insert((row.actor, row.object), v) { + if v < before { + spends.push((i64::from(row.time), row.actor)); + } + } + } + let mut votes: BTreeMap<(String, i64), BTreeMap> = BTreeMap::new(); + for (time, e) in casts { + let (Some(player), Some(slot)) = (&e.player, e.slot) else { + continue; + }; + let mut candidates: Vec = spends + .iter() + .filter(|&&(t, _)| (t - time).abs() <= WINDOW_MS) + .filter(|&&(t, item)| { + cx.owner_writes + .get(&item) + .and_then(|w| value_at(w, u32::try_from(t).unwrap_or(0))) + .and_then(|owner| cx.body_subject.get(&owner)) + == Some(player) + }) + .filter_map(|&(_, item)| cx.actor_class.get(&item).cloned()) + .filter(|class| is_ability_item(class)) + .collect(); + candidates.dedup(); + if let [class] = candidates.as_slice() { + *votes + .entry((player.clone(), slot)) + .or_default() + .entry(class.clone()) + .or_default() += 1; + } + } + let agreed: Vec<((String, i64), String)> = votes + .into_iter() + .filter_map(|(key, classes)| { + (classes.len() == 1).then(|| (key, classes.into_keys().next().expect("one"))) + }) + .collect(); + let mut claims: HashMap<(&str, &str), u32> = HashMap::new(); + for ((player, _), class) in &agreed { + *claims.entry((player, class)).or_default() += 1; + } + agreed + .iter() + .filter(|((player, _), class)| claims[&(player.as_str(), class.as_str())] == 1) + .cloned() + .collect() +} + +/// An agent's held ability item: `/Game/Characters//…/Ability_*_C`. +fn is_ability_item(class: &str) -> bool { + class.starts_with("/Game/Characters/") + && class + .rsplit('/') + .next() + .is_some_and(|leaf| leaf.starts_with("Ability_")) +} diff --git a/native/replay/src/analysis/kills.rs b/native/replay/src/analysis/kills.rs new file mode 100644 index 000000000..94335fb53 --- /dev/null +++ b/native/replay/src/analysis/kills.rs @@ -0,0 +1,232 @@ +//! Kills from Event-chunk `characterDeath` (word0 killer, word1 victim +//! character NetGUIDs), cross-checked against `MulticastNotifyKilledEnemy` +//! as tools/extract_kill_ledger.py and docs/DATA.md ("Kill log") pair them. +//! +//! On 13.00 vrfkit admits no `KillData` route, so assists and the damage +//! source come from elsewhere: an assist is an `AggregateAssists` increment +//! sent at the same millisecond as the death's `MulticastNotifyKilledEnemy` +//! (Event-chunk times run ~8 ms ahead of the packet clock, so the event's own +//! time does not line up), and the source is the `EquippableUsed` of the +//! victim's damage call that reports `bDamageKilledTarget`. + +use std::collections::{BTreeMap, HashMap}; + +use super::Context; +use crate::document::Kill; + +/// The RPCs travel with the event: matched lags in extract_kill_ledger.py +/// measure 5-41 ms against a 50 ms cap. +const LAG_MS: u32 = 50; + +pub(super) fn kills(cx: &mut Context<'_>, teams: &BTreeMap) -> Vec { + let notified: Vec<(u32, u32, u32)> = notify_pairs(cx); + let assists = assist_increments(cx); + let sources = kill_sources(cx); + + let deaths: Vec<_> = cx + .events + .iter() + .filter(|e| e.group == "characterDeath") + .collect(); + let mut notify_times: HashMap = HashMap::new(); + for &(t, _, _) in ¬ified { + *notify_times.entry(t).or_default() += 1; + } + + let mut kills = Vec::new(); + let (mut unvalidated, mut unknown_victims, mut unknown_killers, mut corroborated) = + (0, 0, 0, 0); + let mut ambiguous_assists = 0; + for death in deaths { + let (Some(killer), Some(victim)) = (death.word0, death.word1) else { + unvalidated += 1; + continue; + }; + let t = death.time1; + let Some(victim_subject) = cx.body_subject.get(&victim).cloned() else { + unknown_victims += 1; + continue; + }; + // A self-inflicted death has no killer, as the game's own kill count + // agrees (`AggregateKills` skips it; Clove's ultimate running out). + let killer_subject = cx + .body_subject + .get(&killer) + .filter(|_| killer != victim) + .cloned(); + if killer != 0 && killer != victim && killer_subject.is_none() { + unknown_killers += 1; + } + let notify = notified + .iter() + .find(|&&(nt, k, v)| k == killer && v == victim && nt >= t && nt - t <= LAG_MS) + .map(|&(nt, _, _)| nt); + if notify.is_some() { + corroborated += 1; + } + let mut credited = Vec::new(); + if let Some((nt, players)) = notify.and_then(|nt| Some((nt, assists.get(&nt)?))) { + // Two kills in one millisecond share their assist increments. + if notify_times[&nt] == 1 { + for subject in players { + let teammate = |a: &str, b: &str| matches!((teams.get(a), teams.get(b)), (Some(x), Some(y)) if x == y); + // Assists come from the killer's side, never the victim's. + if Some(subject) != killer_subject.as_ref() + && !teammate(subject, &victim_subject) + { + credited.push(subject.clone()); + } + } + } else { + ambiguous_assists += 1; + } + } + credited.sort(); + kills.push(Kill { + time_ms: i64::from(t), + victim: victim_subject, + killer: killer_subject, + assists: credited, + damage_source: sources + .iter() + .filter(|s| s.victim == victim && s.time.abs_diff(t) <= LAG_MS) + .min_by_key(|s| s.time.abs_diff(t)) + .and_then(|s| cx.class_of(s.equippable)), + }); + } + let total = kills.len() + unvalidated + unknown_victims; + if corroborated < total { + cx.warnings.push(format!( + "{} of {total} death(s) without a matching MulticastNotifyKilledEnemy", + total - corroborated + )); + } + for (n, what) in [ + ( + unvalidated, + "death event(s) off the measured payload layout (dropped)", + ), + ( + unknown_victims, + "death(s) of a pawn no player owns (dropped)", + ), + ( + unknown_killers, + "killer pawn(s) no player owns (killer left absent)", + ), + ( + ambiguous_assists, + "simultaneous deaths whose assists cannot be told apart", + ), + ] { + if n > 0 { + cx.warnings.push(format!("{n} {what}")); + } + } + kills +} + +/// `(time, killer, victim)` of every `MulticastNotifyKilledEnemy` call. +fn notify_pairs(cx: &Context<'_>) -> Vec<(u32, u32, u32)> { + let mut pairs = Vec::new(); + let mut killer: Option<(u32, u32, u32)> = None; // (packet, actor, value) + for row in cx.rows { + let Some(value) = row.int.and_then(|v| u32::try_from(v).ok()) else { + continue; + }; + match &*row.name { + "MulticastNotifyKilledEnemy.KillerCharacter" => { + killer = Some((row.packet, row.actor, value)) + } + "MulticastNotifyKilledEnemy.KilledCharacter" => { + if let Some((p, a, k)) = killer.take() { + if p == row.packet && a == row.actor { + pairs.push((row.time, k, value)); + } + } + } + _ => {} + } + } + pairs +} + +/// Packet time -> subjects whose `AggregateAssists` rose at that millisecond. +fn assist_increments(cx: &Context<'_>) -> HashMap> { + let mut last: HashMap = HashMap::new(); + let mut at: HashMap> = HashMap::new(); + for row in cx.rows.iter().filter(|r| &*r.name == "AggregateAssists") { + let Some(value) = row.int else { continue }; + let before = last.insert(row.actor, value).unwrap_or(0); + if value > before { + if let Some(subject) = cx.state_subject.get(&row.actor) { + at.entry(row.time).or_default().push(subject.clone()); + } + } + } + at +} + +struct KillSource { + time: u32, + victim: u32, + equippable: u32, +} + +/// The equippable of each damage call that killed its target, on the +/// victim's DamageableComponent. One call's parameters share a packet, +/// actor, component and function; a repeated parameter starts the next call. +fn kill_sources(cx: &Context<'_>) -> Vec { + #[derive(Default)] + struct Call { + killed: Option, + equippable: Option, + time: u32, + } + /// (packet, actor, component, function). + type CallKey<'r> = (u32, u32, Option, &'r str); + let mut calls: Vec<(CallKey<'_>, Call)> = Vec::new(); + for row in cx.rows { + let Some((function, member)) = row.name.split_once('.') else { + continue; + }; + if !function.starts_with("MulticastNotifyDamage_") { + continue; + } + let key = (row.packet, row.actor, row.object, function); + let fresh = match calls.last() { + Some((k, call)) => { + *k != key + || (member == "bDamageKilledTarget" && call.killed.is_some()) + || (member == "EquippableUsed" && call.equippable.is_some()) + } + None => true, + }; + if fresh { + calls.push(( + key, + Call { + time: row.time, + ..Call::default() + }, + )); + } + let call = &mut calls.last_mut().expect("pushed").1; + match member { + "bDamageKilledTarget" => call.killed = row.boolean, + "EquippableUsed" => call.equippable = row.int.and_then(|v| u32::try_from(v).ok()), + _ => {} + } + } + calls + .into_iter() + .filter_map(|((_, actor, _, _), call)| { + (call.killed == Some(true)).then_some(())?; + Some(KillSource { + time: call.time, + victim: actor, + equippable: call.equippable.filter(|&e| e != 0)?, + }) + }) + .collect() +} diff --git a/native/replay/src/analysis/mod.rs b/native/replay/src/analysis/mod.rs new file mode 100644 index 000000000..b7446199d --- /dev/null +++ b/native/replay/src/analysis/mod.rs @@ -0,0 +1,396 @@ +//! Decoded stream -> the document of docs/replay-format.md. vrfkit derives +//! these views in Python under `tools/`; each module here names the tool its +//! rules come from, and departs from it only where it says so. + +mod casts; +mod kills; +mod movement; +mod rounds; +mod utility; +mod vitals; + +use std::collections::{BTreeMap, HashMap}; + +use vrf_container::Preamble; +use vrf_schema::NetGuidCache; + +use crate::collect::{Collector, Row}; +use crate::decode::Walked; +use crate::document::{Decoder, Document, Match, Player, Quality, Vec3}; +use crate::guard::GuardVerdict; +use crate::header::{self, DECODER_VERSION, VRFKIT_VERSION}; + +/// Everything the analysis reads, indexed once. +pub(crate) struct Context<'a> { + pub rows: &'a [Row], + pub events: &'a [vrf_export::EventRecord], + pub actors: &'a [vrf_export::ActorRecord], + pub cache: &'a NetGuidCache, + pub duration_ms: i64, + pub players: Vec, + /// Pawn NetGUID -> subject, for every unambiguous `SpawnedCharacter`. + pub body_subject: HashMap, + /// PlayerState NetGUID -> subject. + pub state_subject: HashMap, + /// Actor NetGUID -> class path, from its first open naming one. + pub actor_class: HashMap, + /// Actor NetGUID -> `Owner` / `Instigator` writes `(time, value)`, in order. + pub owner_writes: HashMap>, + pub instigator_writes: HashMap>, + pub warnings: Vec, +} + +pub(crate) struct PlayerIdentity { + pub subject: String, + pub state: u32, + pub bodies: Vec, +} + +impl<'a> Context<'a> { + fn new(collector: &'a Collector, walked: &'a Walked, duration_ms: i64) -> Self { + let mut warnings = Vec::new(); + + // tools/player_identity.py: a pawn named by exactly one PlayerState's + // `SpawnedCharacter` history is that player's body; vrfkit's sink + // keeps the history (`PlayerIdentity::character_net_guids`). + let mut players: Vec = walked + .players + .iter() + .filter_map(|(&state, identity)| { + Some(PlayerIdentity { + subject: identity.subject.as_ref()?.to_ascii_lowercase(), + state, + bodies: identity.character_net_guids.clone(), + }) + }) + .collect(); + players.sort_by_key(|p| p.state); + let unnamed = walked + .players + .values() + .filter(|p| p.subject.is_none()) + .count(); + if unnamed > 0 { + warnings.push(format!( + "{unnamed} PlayerState(s) never replicated a Subject" + )); + } + let mut claims: HashMap> = HashMap::new(); + for p in &players { + for &body in &p.bodies { + claims.entry(body).or_default().push(&p.subject); + } + } + let mut body_subject = HashMap::new(); + for (body, subjects) in claims { + if subjects.len() == 1 { + body_subject.insert(body, subjects[0].to_owned()); + } else { + warnings.push(format!( + "pawn {body} is claimed by {} players; left unattributed", + subjects.len() + )); + } + } + let state_subject = players + .iter() + .map(|p| (p.state, p.subject.clone())) + .collect(); + + let mut actor_class = HashMap::new(); + for a in &collector.actors { + if let Some(class) = &a.class_path { + actor_class + .entry(a.actor_net_guid) + .or_insert_with(|| class.clone()); + } + } + let mut owner_writes: HashMap> = HashMap::new(); + let mut instigator_writes: HashMap> = HashMap::new(); + for row in &collector.rows { + // Only the actor's own property, not a subobject's. + if row.object.is_some() { + continue; + } + let Some(value) = row.int.and_then(|v| u32::try_from(v).ok()) else { + continue; + }; + match &*row.name { + "Owner" => owner_writes + .entry(row.actor) + .or_default() + .push((row.time, value)), + "Instigator" => instigator_writes + .entry(row.actor) + .or_default() + .push((row.time, value)), + _ => {} + } + } + + Self { + rows: &collector.rows, + events: &collector.events, + actors: &collector.actors, + cache: &walked.cache, + duration_ms, + players, + body_subject, + state_subject, + actor_class, + owner_writes, + instigator_writes, + warnings, + } + } + + /// The player behind `guid` at `time`: the guid itself when it is a + /// body, else whoever owns it, following `Owner` then `Instigator` up to + /// four hops (a smoke's manager is owned by the pawn). Like + /// tools/extract_spike_carrier.py's proxy walk, but not limited to one hop. + pub fn subject_behind(&self, guid: u32, time: u32) -> Option<&str> { + self.subject_behind_depth(guid, time, 4) + } + + fn subject_behind_depth(&self, guid: u32, time: u32, depth: u32) -> Option<&str> { + if let Some(subject) = self.body_subject.get(&guid) { + return Some(subject); + } + if depth == 0 || guid == 0 { + return None; + } + for writes in [&self.owner_writes, &self.instigator_writes] { + if let Some(next) = writes.get(&guid).and_then(|w| value_at(w, time)) { + if next != guid { + if let Some(subject) = self.subject_behind_depth(next, time, depth - 1) { + return Some(subject); + } + } + } + } + None + } + + /// The class path of an actor, else the GUID table's path. + pub fn class_of(&self, guid: u32) -> Option { + self.actor_class + .get(&guid) + .cloned() + .or_else(|| self.cache.get_path_by_guid(guid).map(str::to_owned)) + } +} + +/// The last non-zero value written at or before `time`; failing that, the +/// first non-zero value written (an actor's owner arrives in its open packet, +/// which can carry a later `time_ms` than the event asking). +pub(crate) fn value_at(writes: &[(u32, u32)], time: u32) -> Option { + writes + .iter() + .rev() + .find(|&&(t, v)| t <= time && v != 0) + .or_else(|| writes.iter().find(|&&(_, v)| v != 0)) + .map(|&(_, v)| v) +} + +/// `(x,y,z)` as vrfkit renders a VectorDouble. +pub(crate) fn parse_vector(text: &str) -> Option { + let inner = text.trim().strip_prefix('(')?.strip_suffix(')')?; + let mut parts = inner.split(',').map(|p| p.trim().parse::().ok()); + let v = [parts.next()??, parts.next()??, parts.next()??]; + (parts.next().is_none() && v.iter().all(|c| c.is_finite())).then_some(v) +} + +/// The result of the analysis: the JSON document and the movement blob. +pub struct Analysed { + pub document: Document, + pub blob: Vec, +} + +pub(crate) fn analyse( + preamble: &Preamble, + collector: &Collector, + walked: &Walked, + guard: GuardVerdict, +) -> Analysed { + let probe = header::Probe::from_preamble(preamble); + let mut cx = Context::new(collector, walked, probe.duration_ms); + + let agents: HashMap> = probe + .players + .iter() + .map(|p| (p.subject.clone(), p.agent_id.clone())) + .collect(); + let teams = team_by_subject(&cx); + let players: Vec = cx + .players + .iter() + .map(|p| Player { + subject: p.subject.clone(), + agent_id: agents.get(&p.subject).cloned().flatten(), + team: teams.get(&p.subject).cloned(), + name: None, + character_guids: p.bodies.clone(), + }) + .collect(); + for p in &players { + if p.agent_id.is_none() { + cx.warnings.push(format!( + "player {} has no agent in the header's playerLoadouts", + p.subject + )); + } + if p.team.is_none() { + cx.warnings.push(format!( + "player {} has no team in any combat report", + p.subject + )); + } + } + + let rounds = rounds::rounds(&mut cx); + let kills = kills::kills(&mut cx, &teams); + let vitals = vitals::vitals(&mut cx); + let utility = utility::utility(&mut cx); + let casts = casts::casts(&mut cx, &rounds); + let (movement, blob) = movement::movement(&mut cx, &collector.moves); + + let stats = &walked.stats; + let net = &walked.net; + let mut warnings = std::mem::take(&mut cx.warnings); + for (name, value) in [ + ("malformed packets", net.malformed_packets), + ("movement decode errors", stats.movement_rpc_errors), + ("struct-blob decode failures", stats.struct_blobs_failed), + ("array leaf decode errors", stats.array_leaf_decode_errors), + ("unknown chunks", u64::from(walked.unknown_chunks)), + ( + "event chunks off the measured layout", + u64::from(walked.event_layout_mismatches), + ), + ] { + if value > 0 { + warnings.push(format!("{value} {name}")); + } + } + + let document = Document { + decoder: Decoder { + name: "icarus_replay", + version: DECODER_VERSION, + vrfkit: VRFKIT_VERSION, + }, + match_: Match { + id: probe.id, + map_path: probe.map_path, + build: probe.build, + duration_ms: probe.duration_ms, + recorded_at: probe.recorded_at, + game_mode: game_mode( + walked + .cache + .net_guid_entries() + .iter() + .map(|e| (e.path, e.outer_net_guid)), + &walked.cache, + ), + }, + players, + rounds, + kills, + vitals, + utility, + casts, + movement, + quality: Quality { + transform_verified: guard.verified, + decode_errors: stats.overlay.decoded_err, + warnings, + }, + }; + Analysed { document, blob } +} + +/// Team per subject from the combat reports: every `Interactions[i]` +/// element names a participant's subject and team (`ParticipantSubject`, +/// `ParticipantTeamName`). Teams are fixed for a match (sides swap, teams do +/// not), so a subject reported on two teams is a conflict and stays absent. +fn team_by_subject(cx: &Context<'_>) -> BTreeMap { + // (component actor, element indices) -> (subject, team), last write wins. + /// (subject, team) as last written. + type Participant = (Option, Option); + let mut elements: HashMap<(u32, Vec>), Participant> = HashMap::new(); + for row in cx.rows { + let path = crate::fieldpath::FieldPath::parse(&row.name); + if path.segments.first().map(|s| s.base) != Some("Rounds") { + continue; + } + let slot = elements.entry((row.actor, path.indices())).or_default(); + match path.leaf() { + "ParticipantSubject" => slot.0 = row.text.as_ref().map(|s| s.to_ascii_lowercase()), + "ParticipantTeamName" => slot.1 = row.text.clone(), + _ => {} + } + } + let mut seen: BTreeMap> = BTreeMap::new(); + for (subject, team) in elements.into_values() { + if let (Some(subject), Some(team)) = (subject, team) { + if team == "Red" || team == "Blue" { + let teams = seen.entry(subject).or_default(); + if !teams.contains(&team) { + teams.push(team); + } + } + } + } + seen.into_iter() + .filter(|(_, teams)| teams.len() == 1) + .map(|(subject, mut teams)| (subject, teams.remove(0))) + .collect() +} + +/// The game mode class, `/Game/GameModes/<..>/GameMode.GameMode_C`, +/// from the GUID table (the game state names it as a dependency). +fn game_mode<'p>( + entries: impl Iterator)>, + cache: &NetGuidCache, +) -> Option { + for (path, outer) in entries { + if !path.ends_with("GameMode_C") || path.contains('/') { + continue; + } + let Some(outer_path) = outer.and_then(|o| cache.get_path_by_guid(o)) else { + continue; + }; + if outer_path.starts_with("/Game/GameModes/") { + return Some(format!("{outer_path}.{path}")); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vectors_parse_as_vrfkit_renders_them() { + assert_eq!(parse_vector("(1.5,-2,3e2)"), Some([1.5, -2.0, 300.0])); + assert_eq!(parse_vector("(1,2)"), None); + assert_eq!(parse_vector("(1,2,3,4)"), None); + assert_eq!(parse_vector("1,2,3"), None); + assert_eq!(parse_vector("(NaN,0,0)"), None); + } + + #[test] + fn owner_lookup_prefers_the_last_prior_non_zero_write() { + let writes = [(10, 5), (20, 0), (30, 7)]; + assert_eq!(value_at(&writes, 25), Some(5)); + assert_eq!(value_at(&writes, 30), Some(7)); + assert_eq!( + value_at(&writes, 5), + Some(5), + "falls back to the first write" + ); + assert_eq!(value_at(&[(1, 0)], 5), None); + } +} diff --git a/native/replay/src/analysis/movement.rs b/native/replay/src/analysis/movement.rs new file mode 100644 index 000000000..a926d4e13 --- /dev/null +++ b/native/replay/src/analysis/movement.rs @@ -0,0 +1,122 @@ +//! Movement records (docs/replay-format.md, "Movement records"): every +//! decoded move of every body a player had, sorted by time, minus the +//! moves of a body parked off-map between lives. + +use std::collections::BTreeMap; + +use super::Context; +use crate::collect::Move; +use crate::container::MovementSample; +use crate::document::MovementSpan; + +/// docs/DATA.md ("Minimap projection"): hidden actors park at x ≈ -50000, +/// z ≈ -49900; both are checked, since a fall passes through that z alone. +/// A parked body drifts: c8989335's 5,245 parked moves span x -51020..-49008 +/// but z only -49919..-49900. +fn parked(m: &Move) -> bool { + (m.pos[0] + 50_000.0).abs() < 5_000.0 && (m.pos[2] + 49_900.0).abs() < 100.0 +} + +/// The wire's pitch is 0..360 with looking down past 360; the contract's is +/// signed, positive up. +fn signed_pitch(pitch: f32) -> f32 { + if pitch > 180.0 { pitch - 360.0 } else { pitch } +} + +pub(super) fn movement( + cx: &mut Context<'_>, + moves: &[Move], +) -> (BTreeMap, Vec) { + let mut by_body: std::collections::HashMap> = std::collections::HashMap::new(); + for m in moves { + by_body.entry(m.character).or_default().push(m); + } + let mut blob = Vec::new(); + let mut spans = BTreeMap::new(); + let mut dropped = 0usize; + for p in &cx.players { + let mut mine: Vec<&Move> = p + .bodies + .iter() + .filter(|b| cx.body_subject.get(b) == Some(&p.subject)) + .flat_map(|b| by_body.get(b).into_iter().flatten().copied()) + .collect(); + let before = mine.len(); + mine.retain(|m| !parked(m)); + dropped += before - mine.len(); + // Stable: moves sharing a millisecond keep wire order. + mine.sort_by_key(|m| m.time); + let offset = blob.len() as u64; + for m in &mine { + MovementSample { + time_ms: i32::try_from(m.time).unwrap_or(i32::MAX), + x: m.pos[0], + y: m.pos[1], + z: m.pos[2], + yaw: m.yaw, + pitch: signed_pitch(m.pitch), + } + .write_to(&mut blob); + } + spans.insert( + p.subject.clone(), + MovementSpan { + offset, + count: mine.len() as u64, + }, + ); + } + // Pawns that move but are no `SpawnedCharacter` body: possessed devices, + // Clove's post-death form. Named by class so a lost body would show. + let mut unclaimed: BTreeMap = BTreeMap::new(); + for (guid, list) in &by_body { + if !cx.body_subject.contains_key(guid) { + let class = cx + .actor_class + .get(guid) + .map_or("?", |c| c.rsplit('.').next().unwrap_or(c)); + *unclaimed.entry(class.to_owned()).or_default() += list.len(); + } + } + if !unclaimed.is_empty() { + let list: Vec = unclaimed.iter().map(|(c, n)| format!("{c} {n}")).collect(); + cx.warnings.push(format!( + "movement of pawns that are no player's body: {}", + list.join(", ") + )); + } + if dropped > 0 { + cx.warnings + .push(format!("{dropped} parked movement record(s) dropped")); + } + (spans, blob) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parking_needs_both_x_and_z() { + let at = |x: f32, z: f32| Move { + time: 0, + character: 1, + pos: [x, 0.0, z], + yaw: 0.0, + pitch: 0.0, + }; + assert!(parked(&at(-50_000.0, -49_900.0))); + assert!(parked(&at(-51_020.0, -49_919.0)), "a drifting parked body"); + assert!(!parked(&at(-50_000.0, 100.0))); + assert!( + !parked(&at(1_000.0, -49_900.0)), + "a fall passes through that z" + ); + } + + #[test] + fn pitch_is_signed() { + assert_eq!(signed_pitch(10.0), 10.0); + assert_eq!(signed_pitch(350.0), -10.0); + } +} diff --git a/native/replay/src/analysis/rounds.rs b/native/replay/src/analysis/rounds.rs new file mode 100644 index 000000000..e6ecd14a1 --- /dev/null +++ b/native/replay/src/analysis/rounds.rs @@ -0,0 +1,452 @@ +//! Rounds, after tools/extract_rounds.py: one round per `MulticastSetPhase` +//! cycle on the game state (2 reset, 3 round start, 4 buy phase end, 5 post +//! round, 6 side switch), Event-chunk facts filed into a round by time, and +//! `RoundResults` joined by `RoundNumber`. Spike facts add the RPCs vrfkit's +//! docs/DATA.md ("Spike & objective") names; the economy is ours. + +use std::collections::HashMap; + +use super::{Context, parse_vector}; +use crate::document::{Defuse, Economy, Plant, Round}; +use crate::fieldpath::FieldPath; + +/// One `RoundResults[i]` element: member -> (integer, text) as written last. +type ResultMembers = HashMap, Option)>; + +#[derive(Default, Clone)] +struct Phases { + reset: Option, + start: Option, + buy_end: Option, + post_round: Option, + side_switch: Option, +} + +impl Phases { + fn slot(&mut self, phase: i64) -> Option<&mut Option> { + Some(match phase { + 2 => &mut self.reset, + 3 => &mut self.start, + 4 => &mut self.buy_end, + 5 => &mut self.post_round, + 6 => &mut self.side_switch, + _ => return None, + }) + } + + fn open(&self) -> Option { + [ + self.reset, + self.start, + self.buy_end, + self.post_round, + self.side_switch, + ] + .into_iter() + .flatten() + .min() + } +} + +/// One fact filed into a round by time; two of a kind in one round leave +/// the fact unset rather than pick one (extract_rounds.py "never pick one"). +#[derive(Default)] +struct Placed { + round_number: Vec<(u32, i64)>, + plant: Vec<(u32, ())>, + defuse: Vec<(u32, ())>, + explode: Vec<(u32, ())>, + site: Vec<(u32, i64)>, +} + +pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { + let mut phases: Vec = Vec::new(); + let mut repeated = 0; + let mut results: HashMap = HashMap::new(); + let mut sites = Vec::new(); + for row in cx.rows { + if &*row.name == "MulticastSetPhase.NewPhase" { + let Some(phase) = row.int else { continue }; + if !(2..=6).contains(&phase) { + continue; + } + if phase == 2 || phases.is_empty() { + phases.push(Phases::default()); + } + let slot = phases + .last_mut() + .expect("pushed") + .slot(phase) + .expect("2..=6"); + if slot.is_none() { + *slot = Some(row.time); + } else { + repeated += 1; + } + } else if &*row.name == "PlantedAtSite" { + sites.push((row.time, row.int.unwrap_or(0))); + } else if row.name.starts_with("RoundResults[") { + let path = FieldPath::parse(&row.name); + let (Some(index), 2) = (path.segments[0].index, path.segments.len()) else { + continue; + }; + results + .entry(index) + .or_default() + .insert(path.leaf().to_owned(), (row.int, row.text.clone())); + } + } + if repeated > 0 { + cx.warnings.push(format!( + "{repeated} game phase(s) repeated within a round (first kept)" + )); + } + // The last side switch resets into a round that never starts. + if phases.last().is_some_and(|p| p.start.is_none()) { + phases.pop(); + } + + let opens: Vec = phases + .iter() + .map(|p| p.open().expect("a phase is set")) + .collect(); + let place = |t: u32| opens.partition_point(|&o| o <= t).checked_sub(1); + let mut placed: Vec = phases.iter().map(|_| Placed::default()).collect(); + let mut before_first = 0; + for event in cx.events { + let Some(i) = place(event.time1) else { + before_first += 1; + continue; + }; + let t = event.time1; + match event.group.as_str() { + "roundStarted" => { + if let Some(word) = event.word0 { + placed[i].round_number.push((t, i64::from(word))); + } + } + "spikePlanted" => placed[i].plant.push((t, ())), + "spikeDefused" => placed[i].defuse.push((t, ())), + "spikeExploded" => placed[i].explode.push((t, ())), + _ => {} + } + } + for &(t, site) in &sites { + if let Some(i) = place(t) { + placed[i].site.push((t, site)); + } + } + if before_first > 0 { + cx.warnings.push(format!( + "{before_first} round event(s) before the first round" + )); + } + + let by_number: HashMap = results + .values() + .filter_map(|r| Some((r.get("RoundNumber")?.0?, r))) + .collect(); + let text = |r: &ResultMembers, k: &str| r.get(k).and_then(|(_, s)| s.clone()); + + let plants = spike_rpcs(cx, "BombPlantedRPC.BombPlanter"); + let plant_locations = spike_text(cx, "BombPlantedRPC.PlantLocation"); + let defusers = spike_rpcs(cx, "BombDefusedRPC.DefusingCharacter"); + + let mut rounds = Vec::new(); + let mut unknown_results = Vec::new(); + for (ordinal, (p, facts)) in phases.iter().zip(&placed).enumerate() { + let single = |v: &[(u32, i64)]| (v.len() == 1).then(|| v[0]); + let single_t = |v: &[(u32, ())]| (v.len() == 1).then(|| v[0].0); + let round_number = single(&facts.round_number); + if facts.round_number.len() > 1 { + cx.warnings.push(format!( + "round {ordinal}: {} roundStarted events", + facts.round_number.len() + )); + } + if let Some((_, n)) = round_number { + if n != ordinal as i64 { + cx.warnings.push(format!( + "round {ordinal} is numbered {n} by its roundStarted event" + )); + } + } + let start = round_number + .map(|(t, _)| t) + .or(p.start) + .expect("rounds without a start are dropped"); + let end = match p.post_round.or_else(|| opens.get(ordinal + 1).copied()) { + Some(end) => i64::from(end), + None => { + cx.warnings.push(format!( + "round {ordinal} has no end phase; it ends with the replay" + )); + cx.duration_ms + } + }; + let result = round_number.and_then(|(_, n)| by_number.get(&n).copied()); + let winning_team = result.and_then(|r| text(r, "WinningTeam")); + let role = result.and_then(|r| text(r, "WinningTeamRole")); + let attacking_team = match (winning_team.as_deref(), role.as_deref()) { + (Some(team), Some("attacker")) => Some(team.to_owned()), + (Some(team), Some("defender")) => other_team(team).map(str::to_owned), + _ => None, + }; + let raw_result = result.and_then(|r| text(r, "RoundResult")); + let end_reason = raw_result.as_deref().and_then(end_reason); + if let (Some(raw), None) = (&raw_result, end_reason) { + unknown_results.push(raw.clone()); + } + + let plant = single_t(&facts.plant).map(|t| { + let rpc = nearest(&plants, t); + Plant { + time_ms: i64::from(t), + site: single(&facts.site).map_or( + // PlantedAtSite is absent when it holds the default, site 0. + if facts.site.is_empty() { + Some("A") + } else { + None + }, + |(_, s)| site_letter(s), + ), + subject: rpc.and_then(|(rt, g)| cx.subject_behind(g, rt).map(str::to_owned)), + position: nearest(&plant_locations, t).and_then(|(_, s)| parse_vector(s)), + } + }); + let defuse = single_t(&facts.defuse).map(|t| Defuse { + time_ms: i64::from(t), + subject: nearest(&defusers, t) + .and_then(|(rt, g)| cx.subject_behind(g, rt).map(str::to_owned)), + }); + rounds.push(Round { + index: ordinal as u32, + start_ms: i64::from(start), + combat_start_ms: p.buy_end.map(i64::from), + end_ms: end, + attacking_team, + winning_team, + end_reason, + plant, + defuse, + detonate_ms: single_t(&facts.explode).map(i64::from), + economy: economy(cx, p.buy_end), + }); + } + if !unknown_results.is_empty() { + unknown_results.sort(); + unknown_results.dedup(); + cx.warnings.push(format!( + "unknown RoundResult value(s): {}", + unknown_results.join(", ") + )); + } + fill_attackers(cx, &mut rounds, &phases); + rounds +} + +/// Rounds whose `RoundResults` entry is missing take their attackers from a +/// round of the same half: sides hold between side switches (phase 6, sent +/// on the round before the switch). +fn fill_attackers(cx: &mut Context<'_>, rounds: &mut [Round], phases: &[Phases]) { + let mut half = 0; + let halves: Vec = phases + .iter() + .map(|p| { + let this = half; + if p.side_switch.is_some() { + half += 1; + } + this + }) + .collect(); + let mut by_half: HashMap> = HashMap::new(); + for (r, &h) in rounds.iter().zip(&halves) { + if let Some(team) = &r.attacking_team { + by_half.entry(h).or_default().push(team.clone()); + } + } + for (h, teams) in &by_half { + if teams.iter().any(|t| t != &teams[0]) { + cx.warnings + .push(format!("attackers change within side-switch segment {h}")); + } + } + for (r, h) in rounds.iter_mut().zip(halves) { + if r.attacking_team.is_none() { + if let Some(teams) = by_half.get(&h).filter(|t| t.iter().all(|x| x == &t[0])) { + r.attacking_team = Some(teams[0].clone()); + } else { + cx.warnings + .push(format!("round {}: attacking team unknown", r.index)); + } + } + } +} + +fn other_team(team: &str) -> Option<&'static str> { + match team { + "Red" => Some("Blue"), + "Blue" => Some("Red"), + _ => None, + } +} + +/// `RoundResults[].RoundResult` -> the contract's end reasons. The wire +/// strings seen on 13.00: "elimination", "defuse", "detonate", "timeout", +/// "surrendered" (the last from extract_rounds.py's `AWARDED`). +fn end_reason(raw: &str) -> Option<&'static str> { + Some(match raw { + "elimination" => "elimination", + "detonate" | "bomb detonated" => "detonated", + "defuse" | "bomb defused" => "defused", + "timeout" | "round timer expired" => "time", + "surrendered" => "surrender", + _ => return None, + }) +} + +/// `TimedBomb.PlantedAtSite` is an enum ordinal; vrfkit checked it against +/// the game's plant volumes (docs/DATA.md). Ordinals name sites in order. +fn site_letter(ordinal: i64) -> Option<&'static str> { + ["A", "B", "C"].get(usize::try_from(ordinal).ok()?).copied() +} + +/// `(time, value)` of every row named `name` holding a NetGUID. +fn spike_rpcs(cx: &Context<'_>, name: &str) -> Vec<(u32, u32)> { + cx.rows + .iter() + .filter(|r| &*r.name == name) + .filter_map(|r| Some((r.time, u32::try_from(r.int?).ok()?))) + .collect() +} + +fn spike_text<'r>(cx: &Context<'r>, name: &str) -> Vec<(u32, &'r str)> { + cx.rows + .iter() + .filter(|r| &*r.name == name) + .filter_map(|r| Some((r.time, r.text.as_deref()?))) + .collect() +} + +/// The RPC sent with an Event-chunk fact: same frame, within 50 ms (the +/// event clock and packet clock agree to the millisecond on 13.00). +fn nearest(rows: &[(u32, T)], t: u32) -> Option<(u32, T)> { + rows.iter() + .copied() + .filter(|&(rt, _)| rt.abs_diff(t) <= 50) + .min_by_key(|&(rt, _)| rt.abs_diff(t)) +} + +/// Shield points of an armour item, by class: docs/DATA.md measured each +/// armour section's maximum as Heavy 50, Light 25, Plasma 25. +pub(super) fn armor_points(class: &str) -> Option { + let leaf = class.rsplit('.').next()?; + Some(match leaf { + "HeavyArmorItem_C" => 50, + "LightArmorItem_C" | "PlasmaArmorItem_C" => 25, + _ => return None, + }) +} + +/// Per player at the end of the buy phase: credits (`Money`, on the +/// PlayerState's MoneyManagementComponent), the gun they own (a primary +/// over a sidearm) and the armour they wear. +fn economy(cx: &Context<'_>, buy_end: Option) -> Vec { + let Some(t) = buy_end else { + return Vec::new(); + }; + // Equippables open at `t`, by owner body. + let mut open: HashMap = HashMap::new(); + for a in cx.actors.iter().filter(|a| a.time_ms <= t) { + match a.event { + "open" => { + open.insert(a.actor_net_guid, true); + } + "close" => { + open.insert(a.actor_net_guid, false); + } + _ => {} + } + } + let mut owned: HashMap> = HashMap::new(); + for (&guid, &is_open) in &open { + let Some(class) = cx.actor_class.get(&guid) else { + continue; + }; + if !is_open + || !(class.starts_with("/Game/Equippables/Guns/") || armor_points(class).is_some()) + { + continue; + } + let Some(writes) = cx.owner_writes.get(&guid) else { + continue; + }; + // Ownership needs a write at or before `t`, and its latest value. + if let Some(&(_, owner)) = writes.iter().rev().find(|&&(wt, _)| wt <= t) { + owned.entry(owner).or_default().push(class); + } + } + let mut money: HashMap = HashMap::new(); + for row in cx + .rows + .iter() + .filter(|r| r.time <= t && &*r.name == "Money") + { + if row.group.ends_with("MoneyManagementComponent") { + if let Some(v) = row.int { + money.insert(row.actor, v); + } + } + } + cx.players + .iter() + .map(|p| { + let items: Vec<&str> = p + .bodies + .iter() + .flat_map(|b| owned.get(b).into_iter().flatten().copied()) + .collect(); + let gun = |sidearm: bool| { + let mut guns: Vec<&str> = items + .iter() + .copied() + .filter(|c| { + c.starts_with("/Game/Equippables/Guns/") + && c.contains("/Sidearms/") == sidearm + }) + .collect(); + guns.sort_unstable(); + guns.dedup(); + (guns.len() == 1).then(|| guns[0].to_owned()) + }; + Economy { + subject: p.subject.clone(), + credits: money.get(&p.state).copied(), + loadout_value: None, + weapon: gun(false).or_else(|| gun(true)), + armor: items.iter().filter_map(|c| armor_points(c)).max(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sites_and_reasons_map_as_documented() { + assert_eq!(site_letter(0), Some("A")); + assert_eq!(site_letter(2), Some("C")); + assert_eq!(site_letter(3), None); + assert_eq!(end_reason("defuse"), Some("defused")); + assert_eq!(end_reason("something new"), None); + assert_eq!( + armor_points("/Game/Gear/HeavyArmorItem.HeavyArmorItem_C"), + Some(50) + ); + assert_eq!(armor_points("/Game/Gear/Other.Other_C"), None); + } +} diff --git a/native/replay/src/analysis/utility.rs b/native/replay/src/analysis/utility.rs new file mode 100644 index 000000000..1e92f6a23 --- /dev/null +++ b/native/replay/src/analysis/utility.rs @@ -0,0 +1,176 @@ +//! Utility: every world object an agent ability spawns, from actor open to +//! close. The class rule is tools/extract_ability_lifecycle.py's +//! `classify_candidate` (below `/Game/Characters//` with an ability +//! path segment), minus the held ability items themselves (`Ability_*`, +//! `Equippable_*`), which live in inventories rather than the world. +//! Owners come from `Owner`/`Instigator` as that tool reads them, followed to +//! a player's body (`Context::subject_behind`). + +use std::collections::HashMap; + +use super::{Context, parse_vector}; +use crate::document::{Utility, Vec3}; + +pub(super) fn utility(cx: &mut Context<'_>) -> Vec { + // ReplicatedMovement locations and wall points, per actor, in order. + let mut moves: HashMap> = HashMap::new(); + let mut points: HashMap> = HashMap::new(); + for row in cx.rows.iter().filter(|r| r.object.is_none()) { + match &*row.name { + "ReplicatedMovement" => { + if let Some(loc) = row.text.as_deref().and_then(movement_location) { + moves.entry(row.actor).or_default().push([ + f64::from(row.time), + loc[0], + loc[1], + loc[2], + ]); + } + } + "MulticastAddSmokeScreenPoint.Translation" => { + if let Some(p) = row.text.as_deref().and_then(parse_vector) { + points.entry(row.actor).or_default().push(p); + } + } + _ => {} + } + } + + let mut open: HashMap = HashMap::new(); + let mut out: Vec = Vec::new(); + let (mut no_position, mut reopened, mut no_owner) = (0, 0, 0); + for a in cx.actors { + match a.event { + "open" => { + let Some(class) = a.class_path.as_deref().filter(|c| is_utility(c)) else { + continue; + }; + if open.contains_key(&a.actor_net_guid) { + reopened += 1; + } + let spawn = match (a.spawn_x, a.spawn_y, a.spawn_z) { + (Some(x), Some(y), Some(z)) => Some([f64::from(x), f64::from(y), f64::from(z)]), + _ => None, + }; + let first_move = moves.get(&a.actor_net_guid).and_then(|m| { + m.iter() + .find(|p| p[0] >= f64::from(a.time_ms)) + .map(|p| [p[1], p[2], p[3]]) + }); + let Some(position) = spawn.or(first_move) else { + no_position += 1; + continue; + }; + let owner = cx + .subject_behind(a.actor_net_guid, a.time_ms) + .map(str::to_owned); + if owner.is_none() { + no_owner += 1; + } + open.insert(a.actor_net_guid, out.len()); + out.push(Utility { + id: a.actor_net_guid, + class_path: class.to_owned(), + owner, + spawn_ms: i64::from(a.time_ms), + end_ms: None, + position, + yaw: a.spawn_yaw.map(f64::from), + path: None, + points: None, + }); + } + // Dormancy is not destruction (vrfkit CLAUDE.md): only a close ends it. + "close" => { + if let Some(i) = open.remove(&a.actor_net_guid) { + out[i].end_ms = Some(i64::from(a.time_ms)); + } + } + _ => {} + } + } + for u in &mut out { + let within = |t: f64| t >= u.spawn_ms as f64 && u.end_ms.is_none_or(|e| t <= e as f64); + if let Some(m) = moves.get(&u.id) { + let mut path: Vec<[f64; 4]> = Vec::new(); + for p in m.iter().filter(|p| within(p[0])) { + // Consecutive repeats of one location say nothing new. + if path.last().is_none_or(|l| l[1..] != p[1..]) { + path.push(*p); + } + } + if path.len() > 1 || path.first().is_some_and(|p| p[1..] != u.position[..]) { + u.path = Some(path); + } + } + if let Some(p) = points.get(&u.id) { + u.points = Some(p.clone()); + } + } + for (n, what) in [ + (no_position, "utility actor(s) with no position (dropped)"), + (reopened, "utility actor GUID(s) reopened while open"), + (no_owner, "utility actor(s) with no player owner"), + ] { + if n > 0 { + cx.warnings.push(format!("{n} {what}")); + } + } + out +} + +/// `/Game/Characters//…/Ability_*|Abilities/…/._C`, +/// not the held `Ability_*` / `Equippable_*` item. +fn is_utility(class_path: &str) -> bool { + let parts: Vec<&str> = class_path.split('/').collect(); + if parts.len() < 5 || parts[..3] != ["", "Game", "Characters"] || parts[3].starts_with('_') { + return false; + } + let ability_segment = parts[4..parts.len() - 1].iter().any(|p| { + let p = p.to_ascii_lowercase(); + p == "abilities" || p.starts_with("ability_") + }); + let leaf = parts[parts.len() - 1]; + ability_segment && !leaf.starts_with("Ability_") && !leaf.starts_with("Equippable_") +} + +/// `location` of a ReplicatedMovement JSON object, in world units. +fn movement_location(json: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let l = &v["location"]; + Some([l["x"].as_f64()?, l["y"].as_f64()?, l["z"].as_f64()?]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn world_objects_are_utility_and_held_items_are_not() { + assert!(is_utility( + "/Game/Characters/Killjoy/S0/Ability_Q/Pawn_Killjoy_Q_StealthAlarmbot.Pawn_Killjoy_Q_StealthAlarmbot_C" + )); + assert!(is_utility( + "/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/GameObject_Sarge_4_Smoke_ProductionNEW.GameObject_Sarge_4_Smoke_ProductionNEW_C" + )); + assert!(!is_utility( + "/Game/Characters/Killjoy/S0/Ability_Q/Ability_Killjoy_Q_Alarmbot.Ability_Killjoy_Q_Alarmbot_C" + )); + assert!(!is_utility( + "/Game/Characters/Killjoy/Killjoy_PC.Killjoy_PC_C" + )); + assert!(!is_utility( + "/Game/Characters/_Core/Equippable_Unarmed.Equippable_Unarmed_C" + )); + assert!(!is_utility( + "/Game/Characters/Wushu/S0/Glide/Ability_Wushu_Passive_Glide.Ability_Wushu_Passive_Glide_C" + )); + } + + #[test] + fn movement_json_gives_its_location() { + let json = r#"{"linear_velocity":{"x":0,"y":0,"z":0},"angular_velocity":null,"location":{"x":7979,"y":3932,"z":592}}"#; + assert_eq!(movement_location(json), Some([7979.0, 3932.0, 592.0])); + assert_eq!(movement_location("{}"), None); + } +} diff --git a/native/replay/src/analysis/vitals.rs b/native/replay/src/analysis/vitals.rs new file mode 100644 index 000000000..8df137703 --- /dev/null +++ b/native/replay/src/analysis/vitals.rs @@ -0,0 +1,178 @@ +//! Health and armour per player, from the absolute `LifeResult` the damage, +//! heal and reset RPCs carry per damage section (docs/DATA.md, "Health is +//! absolute, not a subtraction"; vrfkit's section tools keep the raw view). +//! +//! Health is the body's `HealthDamageSection`. Armour is the +//! `AttachedDamageSection` of the armour item the body owns ("Armour is +//! `AttachedDamageSection`, not `ShieldDamageSection`"), which also reports +//! through `MulticastNotifySetLife` while a regenerating shield refills. +//! A section's kind comes from its name in the GUID table. Armour reads +//! `null` until the wire states it: a bought item's full value is never +//! replicated, only later changes (the round's economy carries the tier). + +use std::collections::{BTreeMap, HashMap}; + +use super::{Context, value_at}; +use crate::fieldpath::FieldPath; + +use crate::document::VitalsRow; + +/// One element of one life-change call: (packet, actor, component, element +/// indices, function). +type ElementKey = (u32, u32, Option, Vec>, String); + +/// What a subject's vitals stand at, and which armour section the armour is. +#[derive(Default, Clone, Copy, PartialEq)] +struct Vital { + health: Option, + armor: Option, + armor_section: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Section { + Health, + Armor, +} + +pub(super) fn vitals(cx: &mut Context<'_>) -> BTreeMap> { + // Every reported section result, in wire order: (time, body, section, kind, value). + let mut changes: Vec<(u32, u32, u32, Section, f64)> = Vec::new(); + let mut pending: HashMap, Option)> = HashMap::new(); + let mut order = Vec::new(); + for row in cx.rows { + let path = FieldPath::parse(&row.name); + if path.segments.len() == 3 + && matches!( + path.segments[1].base, + "LifeChangeEvents" | "LifeChangeBySection" + ) + { + let key = ( + row.packet, + row.actor, + row.object, + path.indices(), + path.segments[0].base.to_owned(), + ); + let slot = pending.entry(key.clone()).or_insert_with(|| { + order.push((row.time, key)); + (None, None) + }); + match path.leaf() { + "ChangedComponent" => slot.0 = row.int.and_then(|v| u32::try_from(v).ok()), + "LifeResult" => slot.1 = row.float, + _ => {} + } + } else if &*row.name == "MulticastNotifySetLife.NewLife" { + // On the armour item's section: the item, not a body, is the actor. + let (Some(section), Some(value)) = (row.object, row.float) else { + continue; + }; + if section_kind(cx, section) != Some(Section::Armor) { + continue; + } + let owner = cx + .owner_writes + .get(&row.actor) + .and_then(|w| value_at(w, row.time)); + if let Some(body) = owner.filter(|b| cx.body_subject.contains_key(b)) { + changes.push((row.time, body, section, Section::Armor, value)); + } + } + } + for (time, key) in order { + let body = key.1; + let (Some(section), Some(value)) = pending[&key] else { + continue; + }; + if !cx.body_subject.contains_key(&body) { + continue; + } + // Shield, overheal and agent sections are neither health nor armour. + if let Some(kind) = section_kind(cx, section) { + changes.push((time, body, section, kind, value)); + } + } + // An armour item changing hands: its new owner's armour is that item's, + // unknown until it reports (NaN marks "unknown"). + for (item, section) in armor_sections(cx) { + for &(time, owner) in cx.owner_writes.get(&item).into_iter().flatten() { + if cx.body_subject.contains_key(&owner) { + changes.push((time, owner, section, Section::Armor, f64::NAN)); + } + } + } + changes.sort_by_key(|c| c.0); + + // Per subject: health, armour, and the armour section it belongs to. + let mut state: HashMap = HashMap::new(); + let mut out: BTreeMap> = BTreeMap::new(); + for (time, body, section, kind, value) in changes { + let subject = cx.body_subject[&body].clone(); + let current = state.entry(subject.clone()).or_default(); + let next = match kind { + Section::Health => Vital { + health: Some(round2(value)), + ..*current + }, + // The same item handed back (a survivor at round start) keeps its value. + Section::Armor if value.is_nan() && current.armor_section == Some(section) => *current, + Section::Armor => Vital { + armor: (!value.is_nan()).then(|| round2(value)), + armor_section: Some(section), + ..*current + }, + }; + let changed = (next.health, next.armor) != (current.health, current.armor); + *current = next; + if !changed { + continue; + } + let rows = out.entry(subject).or_default(); + let t = i64::from(time); + // One row per change: a later change at the same millisecond replaces it. + if rows.last().is_some_and(|r| r.0 == t) { + rows.pop(); + } + rows.push((t, next.health, next.armor)); + } + if out.is_empty() { + cx.warnings + .push("no health or armour results replicated".to_owned()); + } + out +} + +/// Armour item actor -> its `AttachedDamageSection` subobject. +fn armor_sections(cx: &Context<'_>) -> Vec<(u32, u32)> { + cx.cache + .net_guid_entries() + .into_iter() + .filter(|e| e.path == "AttachedDamageSection") + .filter_map(|e| { + let item = e.outer_net_guid?; + let class = cx.actor_class.get(&item)?; + super::rounds::armor_points(class).map(|_| (item, e.net_guid)) + }) + .collect() +} + +/// What a damage section is, by its object name in the GUID table. +fn section_kind(cx: &Context<'_>, section: u32) -> Option
{ + match cx.cache.get_path_by_guid(section)? { + "HealthDamageSection" => Some(Section::Health), + "AttachedDamageSection" => { + // Only an armour item's section: its outer is the item actor. + let outer = cx.cache.get_outer_guid(section)?.0; + let class = cx.actor_class.get(&outer)?; + super::rounds::armor_points(class).map(|_| Section::Armor) + } + _ => None, + } +} + +/// The wire's f32 results to two decimals: 99.99999 and 100 are one value. +fn round2(v: f64) -> f64 { + (v * 100.0).round() / 100.0 +} diff --git a/native/replay/src/bin/icarus-replay-dump.rs b/native/replay/src/bin/icarus-replay-dump.rs new file mode 100644 index 000000000..97b5df42e --- /dev/null +++ b/native/replay/src/bin/icarus-replay-dump.rs @@ -0,0 +1,298 @@ +//! Dev tool: decode one replay and write what came out, for inspection. +//! +//! icarus-replay-dump [out-dir] +//! +//! Writes `.icrp` and `.json` (the document, movement summarized) to +//! `out-dir` (default: the current directory) and prints a one-line summary. +//! `--survey` instead writes `.survey.tsv`: every (group, field) the +//! stream carried with its row and typed-value counts, for finding facts; +//! `--rows=A,B` writes `.rows.tsv`, every field row whose name or group +//! contains A or B. Both also write `.guids.tsv`, the GUID table. +//! Oodle archives come from `ICARUS_OODLE_VECTORS` (feature `dev-vectors`). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use icarus_replay::decode::{Control, Observer}; +use icarus_replay::{container, header, oodle}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let survey = args.iter().any(|a| a == "--survey"); + // `--rows=A,B`: every field row whose name or group contains A or B. + let rows: Vec = args + .iter() + .filter_map(|a| a.strip_prefix("--rows=")) + .flat_map(|a| a.split(',').map(str::to_owned)) + .collect(); + let paths: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); + let Some(input) = paths.first() else { + eprintln!("usage: icarus-replay-dump [--survey] [out-dir]"); + std::process::exit(2); + }; + let out_dir = PathBuf::from(paths.get(1).map_or(".", |s| s.as_str())); + let result = if survey || !rows.is_empty() { + run_survey(Path::new(input), &out_dir, rows) + } else { + run_decode(Path::new(input), &out_dir) + }; + if let Err(e) = result { + eprintln!("{}", e.to_json()); + std::process::exit(1); + } +} + +fn run_decode(input: &Path, out_dir: &Path) -> Result<(), icarus_replay::ReplayError> { + let started = Instant::now(); + let probe = header::probe(input)?; + let probe_ms = started.elapsed().as_secs_f64() * 1000.0; + let started = Instant::now(); + let buffer = icarus_replay::decode_file(input, Control::default())?; + let decode_s = started.elapsed().as_secs_f64(); + let parts = container::read(&buffer).expect("our own buffer reads back"); + std::fs::create_dir_all(out_dir)?; + std::fs::write(out_dir.join(format!("{}.icrp", probe.id)), &buffer)?; + let mut doc: serde_json::Value = serde_json::from_str(parts.json).expect("our JSON parses"); + summarize_movement(&mut doc, parts.blob); + let pretty = serde_json::to_string_pretty(&doc).expect("serializes"); + std::fs::write(out_dir.join(format!("{}.json", probe.id)), pretty)?; + println!( + "{} probe {probe_ms:.1} ms, decode {decode_s:.2} s, {} bytes ({} JSON, {} blob)", + probe.id, + buffer.len(), + parts.json.len(), + parts.blob.len() + ); + Ok(()) +} + +/// Replace each player's movement entry with its count, time span and first +/// and last records. +fn summarize_movement(doc: &mut serde_json::Value, blob: &[u8]) { + let Some(movement) = doc["movement"].as_object_mut() else { + return; + }; + for entry in movement.values_mut() { + let offset = entry["offset"].as_u64().unwrap_or(0) as usize; + let count = entry["count"].as_u64().unwrap_or(0) as usize; + let record = |i: usize| { + let at = offset + i * container::MOVEMENT_RECORD_BYTES; + let s = + container::MovementSample::read(&blob[at..at + container::MOVEMENT_RECORD_BYTES]); + serde_json::json!([s.time_ms, s.x, s.y, s.z, s.yaw, s.pitch]) + }; + if count > 0 { + entry["first"] = record(0); + entry["last"] = record(count - 1); + } + } +} + +#[derive(Default)] +struct Cell { + rows: u64, + i64s: u64, + f64s: u64, + bools: u64, + strs: u64, + samples: Vec, +} + +#[derive(Default)] +struct Survey { + rows_filter: Vec, + rows: String, + fields: BTreeMap<(String, String), Cell>, + actors: BTreeMap<(String, &'static str), u64>, + events: BTreeMap, + movement_rows: u64, +} + +impl Observer for Survey { + fn on_event(&mut self, event: vrf_export::EventRecord) { + *self.events.entry(event.group).or_default() += 1; + } + + fn on_packet(&mut self, records: &icarus_replay::RecordBuffers, _: &vrf_schema::NetGuidCache) { + self.movement_rows += records.movement.len() as u64; + for a in &records.actors { + let class = a.class_path.clone().unwrap_or_else(|| "?".into()); + *self.actors.entry((class, a.event)).or_default() += 1; + } + for f in &records.fields { + if !self.rows_filter.is_empty() { + let name = f.field_name.as_deref().unwrap_or(""); + if self + .rows_filter + .iter() + .any(|p| name.contains(p.as_str()) || f.group_path.contains(p.as_str())) + { + let leaf = f.group_path.rsplit('/').next().unwrap_or(""); + let value = f + .value_i64 + .map(|v| v.to_string()) + .or(f.value_f64.map(|v| v.to_string())) + .or(f.value_bool.map(|v| v.to_string())) + .or(f.value_str.clone()) + .unwrap_or_else(|| format!("{}b", f.bit_count)); + self.rows.push_str(&format!( + "{} {} {} {} {} {name} {value} +", + f.time_ms, + f.packet_id, + f.actor_net_guid, + f.object_net_guid.map_or(String::new(), |o| o.to_string()), + leaf + )); + } + continue; + } + let name = f + .field_name + .as_deref() + .map_or_else(|| format!("#{}", f.handle), normalize); + let cell = self + .fields + .entry((f.group_path.to_string(), name)) + .or_default(); + cell.rows += 1; + cell.i64s += u64::from(f.value_i64.is_some()); + cell.f64s += u64::from(f.value_f64.is_some()); + cell.bools += u64::from(f.value_bool.is_some()); + cell.strs += u64::from(f.value_str.is_some()); + if cell.samples.len() < 4 { + let v = if let Some(v) = f.value_i64 { + v.to_string() + } else if let Some(v) = f.value_f64 { + v.to_string() + } else if let Some(v) = f.value_bool { + v.to_string() + } else if let Some(v) = &f.value_str { + v.chars().take(120).collect() + } else { + format!("{}b", f.bit_count) + }; + cell.samples + .push(format!("{}@{}:{v}", f.actor_net_guid, f.time_ms)); + } + } + } +} + +/// `A[3].B_12_0123…ABCD` -> `A[].B`: array indices and Blueprint GUID +/// suffixes dropped, so one row per member. +fn normalize(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut chars = name.chars().peekable(); + while let Some(c) = chars.next() { + if c == '[' { + while let Some(&n) = chars.peek() { + chars.next(); + if n == ']' { + break; + } + } + out.push_str("[]"); + } else { + out.push(c); + } + } + // Blueprint member suffix: __<32 hex> + let mut parts: Vec<&str> = out.split('.').collect(); + let owned: Vec = parts + .iter_mut() + .map(|p| { + if let Some(cut) = p.rfind('_') { + let hex = &p[cut + 1..]; + if hex.len() == 32 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + let head = &p[..cut]; + if let Some(cut2) = head.rfind('_') { + if head[cut2 + 1..].chars().all(|c| c.is_ascii_digit()) { + return head[..cut2].to_owned(); + } + } + } + } + (*p).to_owned() + }) + .collect(); + owned.join(".") +} + +fn run_survey( + input: &Path, + out_dir: &Path, + rows_filter: Vec, +) -> Result<(), icarus_replay::ReplayError> { + let preamble = header::read_preamble(input)?; + let data = std::fs::read(input)?; + let mut survey = Survey { + rows_filter, + ..Survey::default() + }; + let started = Instant::now(); + let decompressor = oodle::default_decompressor(); + let branch = preamble.header.replay_version.branch.clone(); + let walked = icarus_replay::decode::walk( + &data, + &preamble, + &branch, + decompressor.as_ref(), + Control::default(), + &mut survey, + )?; + let mut out = String::new(); + out.push_str(&format!( + "# walk {:.2} s, packets {}, movement rows {}, stream failures {}\n", + started.elapsed().as_secs_f64(), + walked.packets, + survey.movement_rows, + walked.stream_failures.len() + )); + for (group, n) in &survey.events { + out.push_str(&format!("event\t{group}\t{n}\n")); + } + for ((class, event), n) in &survey.actors { + out.push_str(&format!("actor\t{class}\t{event}\t{n}\n")); + } + for ((group, field), c) in &survey.fields { + out.push_str(&format!( + "field\t{group}\t{field}\t{}\ti{} f{} b{} s{}\t{}\n", + c.rows, + c.i64s, + c.f64s, + c.bools, + c.strs, + c.samples.join(" | ") + )); + } + std::fs::create_dir_all(out_dir)?; + let mut guids = walked.cache.net_guid_entries(); + guids.sort_unstable_by_key(|e| e.net_guid); + let guids: String = guids + .iter() + .map(|e| { + let outer = e.outer_net_guid.map_or(String::new(), |o| o.to_string()); + format!( + "{} {outer} {} +", + e.net_guid, e.path + ) + }) + .collect(); + std::fs::write( + out_dir.join(format!("{}.guids.tsv", preamble.info.friendly_name)), + guids, + )?; + if !survey.rows_filter.is_empty() { + let name = format!("{}.rows.tsv", preamble.info.friendly_name); + std::fs::write(out_dir.join(&name), &survey.rows)?; + println!("wrote {name}"); + return Ok(()); + } + let name = format!("{}.survey.tsv", preamble.info.friendly_name); + std::fs::write(out_dir.join(&name), out)?; + println!("wrote {name}"); + Ok(()) +} diff --git a/native/replay/src/collect.rs b/native/replay/src/collect.rs new file mode 100644 index 000000000..099a4975f --- /dev/null +++ b/native/replay/src/collect.rs @@ -0,0 +1,153 @@ +//! The [`Observer`] the decode runs: keeps the few thousand rows the analysis +//! reads out of the million a match replicates, plus actors, movement, +//! events and the transform guard's sample. + +use std::sync::Arc; + +use vrf_export::{ActorRecord, EventRecord}; +use vrf_schema::NetGuidCache; + +use crate::decode::Observer; +use crate::guard::GuardSample; +use crate::vendor::sink::RecordBuffers; + +/// One field row the analysis reads. `name` is vrfkit's field name as +/// exported (array indices and Blueprint suffixes intact). +#[derive(Debug, Clone)] +pub struct Row { + pub time: u32, + pub packet: u32, + pub actor: u32, + pub object: Option, + pub group: Arc, + pub name: Arc, + pub int: Option, + pub float: Option, + pub boolean: Option, + pub text: Option, +} + +/// One movement sample, as compact as the analysis needs it. +#[derive(Debug, Clone, Copy)] +pub struct Move { + pub time: u32, + pub character: u32, + pub pos: [f32; 3], + pub yaw: f32, + pub pitch: f32, +} + +#[derive(Default)] +pub struct Collector { + pub rows: Vec, + pub actors: Vec, + pub moves: Vec, + pub events: Vec, + pub guard: GuardSample, + /// Field rows walked in total, kept or not. + pub field_rows: u64, + /// `wanted` per interned name; the `Arc` held here keeps its address unique. + verdicts: std::collections::HashMap, bool)>, +} + +impl Observer for Collector { + fn on_event(&mut self, event: EventRecord) { + self.events.push(event); + } + + fn on_packet(&mut self, records: &RecordBuffers, _cache: &NetGuidCache) { + self.actors.extend(records.actors.iter().cloned()); + self.moves.extend(records.movement.iter().map(|m| Move { + time: m.time_ms, + character: m.character_net_guid, + pos: [m.pos_x, m.pos_y, m.pos_z], + yaw: m.yaw, + pitch: m.pitch, + })); + for field in &records.fields { + self.field_rows += 1; + if let Some(raw) = &field.raw_bits { + self.guard.add(raw, field.bit_count); + } + let Some(name) = &field.field_name else { + continue; + }; + let key = Arc::as_ptr(name).cast::() as usize; + let keep = self + .verdicts + .entry(key) + .or_insert_with(|| (Arc::clone(name), wanted(name))) + .1; + if !keep { + continue; + } + self.rows.push(Row { + time: field.time_ms, + packet: field.packet_id, + actor: field.actor_net_guid, + object: field.object_net_guid, + group: Arc::clone(&field.group_path), + name: Arc::clone(name), + int: field.value_i64, + float: field.value_f64, + boolean: field.value_bool, + text: field.value_str.clone(), + }); + } + } +} + +/// Field names whose rows the analysis reads (see each consumer for why). +const EXACT: &[&str] = &[ + "Money", + "Owner", + "Instigator", + "AggregateAssists", + "PlantedAtSite", + "AuthResourceAmount", + "ReplicatedMovement", + "MulticastSetPhase.NewPhase", + "BombPlantedRPC.BombPlanter", + "BombPlantedRPC.PlantLocation", + "BombPlantedRPC.PlantSite", + "BombDefusedRPC.DefusingCharacter", + "MulticastActivateBombSiteEffects.BombLocation", + "MulticastNotifyKilledEnemy.KillerCharacter", + "MulticastNotifyKilledEnemy.KilledCharacter", + "MulticastAddSmokeScreenPoint.Translation", + "MulticastNotifySetLife.NewLife", + "MulticastNotifyDamage_Point.bDamageKilledTarget", + "MulticastNotifyDamage_Point.EquippableUsed", + "MulticastNotifyDamage_Base.bDamageKilledTarget", + "MulticastNotifyDamage_Base.EquippableUsed", +]; + +fn wanted(name: &str) -> bool { + if EXACT.contains(&name) { + return true; + } + let path = crate::fieldpath::FieldPath::parse(name); + let first = path.segments.first().map_or("", |s| s.base); + let last = path.leaf(); + match first { + "RoundResults" => true, + "AbilityCastsThisRound" => { + path.segments.len() == 2 + && matches!( + last, + "Player" | "Slot" | "Round" | "CastTime" | "CastLocation" + ) + } + "Rounds" => matches!(last, "ParticipantSubject" | "ParticipantTeamName"), + // `.LifeChangeEvents[i].` (or `LifeChangeBySection`): + // the damage, heal, decay and reset RPCs (docs/DATA.md, "Health is absolute"). + _ => { + path.segments.len() == 3 + && matches!( + path.segments[1].base, + "LifeChangeEvents" | "LifeChangeBySection" + ) + && matches!(last, "ChangedComponent" | "LifeResult") + } + } +} diff --git a/native/replay/src/container.rs b/native/replay/src/container.rs new file mode 100644 index 000000000..417ea7b93 --- /dev/null +++ b/native/replay/src/container.rs @@ -0,0 +1,152 @@ +//! The `.icrp` buffer of docs/replay-format.md ("Container"): magic, format +//! version, the JSON document, zero padding to 8, then the movement blob. + +pub const MAGIC: &[u8; 4] = b"ICRP"; +pub const FORMAT_VERSION: u32 = 1; +const HEADER_BYTES: usize = 12; + +/// Bytes per movement record: i32 timeMs, then f32 x, y, z, yaw, pitch. +pub const MOVEMENT_RECORD_BYTES: usize = 24; + +/// One movement record, as the blob stores it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MovementSample { + pub time_ms: i32, + pub x: f32, + pub y: f32, + pub z: f32, + pub yaw: f32, + pub pitch: f32, +} + +impl MovementSample { + pub fn write_to(&self, out: &mut Vec) { + out.extend(self.time_ms.to_le_bytes()); + for v in [self.x, self.y, self.z, self.yaw, self.pitch] { + out.extend(v.to_le_bytes()); + } + } + + pub fn read(bytes: &[u8]) -> Self { + let f = |i: usize| f32::from_le_bytes(bytes[i..i + 4].try_into().expect("4 bytes")); + Self { + time_ms: i32::from_le_bytes(bytes[0..4].try_into().expect("4 bytes")), + x: f(4), + y: f(8), + z: f(12), + yaw: f(16), + pitch: f(20), + } + } +} + +/// Assemble a buffer from the JSON document and the blob. +pub fn write(json: &str, blob: &[u8]) -> Vec { + let json_len = u32::try_from(json.len()).expect("document under 4 GiB"); + let blob_start = (HEADER_BYTES + json.len()).next_multiple_of(8); + let mut out = Vec::with_capacity(blob_start + blob.len()); + out.extend(MAGIC); + out.extend(FORMAT_VERSION.to_le_bytes()); + out.extend(json_len.to_le_bytes()); + out.extend(json.as_bytes()); + out.resize(blob_start, 0); + out.extend(blob); + out +} + +/// A buffer split into its parts; the dump tool and tests read it back. +#[derive(Debug, PartialEq)] +pub struct Parts<'a> { + pub format_version: u32, + pub json: &'a str, + pub blob: &'a [u8], +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ReadError { + BadMagic, + Truncated, + NonZeroPadding, + NotUtf8, +} + +pub fn read(buffer: &[u8]) -> Result, ReadError> { + if buffer.len() < HEADER_BYTES { + return Err(ReadError::Truncated); + } + if &buffer[..4] != MAGIC { + return Err(ReadError::BadMagic); + } + let u32_at = |i: usize| u32::from_le_bytes(buffer[i..i + 4].try_into().expect("4 bytes")); + let json_end = HEADER_BYTES + u32_at(8) as usize; + let blob_start = json_end.next_multiple_of(8); + if buffer.len() < blob_start { + return Err(ReadError::Truncated); + } + if buffer[json_end..blob_start].iter().any(|&b| b != 0) { + return Err(ReadError::NonZeroPadding); + } + Ok(Parts { + format_version: u32_at(4), + json: std::str::from_utf8(&buffer[HEADER_BYTES..json_end]) + .map_err(|_| ReadError::NotUtf8)?, + blob: &buffer[blob_start..], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_matches_the_contract() { + let buf = write("{}", &[0xAA; 3]); + assert_eq!(&buf[..4], b"ICRP"); + assert_eq!(u32::from_le_bytes(buf[4..8].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(buf[8..12].try_into().unwrap()), 2); + assert_eq!(&buf[12..14], b"{}"); + // 14 bytes pad to 16; the blob starts there. + assert_eq!(&buf[14..16], &[0, 0]); + assert_eq!(&buf[16..], &[0xAA; 3]); + } + + #[test] + fn padding_is_zero_and_blob_is_8_aligned_for_every_json_length() { + for len in 0..20 { + let json = "x".repeat(len); + let buf = write(&json, &[1]); + let parts = read(&buf).unwrap(); + assert_eq!(parts.json, json); + assert_eq!(parts.blob, &[1]); + assert_eq!((buf.len() - 1) % 8, 0, "blob offset for json length {len}"); + } + } + + #[test] + fn movement_records_round_trip_in_24_bytes() { + let sample = MovementSample { + time_ms: -3, + x: 1.5, + y: -2.0, + z: 3.25, + yaw: 90.0, + pitch: -10.5, + }; + let mut blob = Vec::new(); + sample.write_to(&mut blob); + assert_eq!(blob.len(), MOVEMENT_RECORD_BYTES); + assert_eq!(MovementSample::read(&blob), sample); + } + + #[test] + fn reader_refuses_damaged_buffers() { + assert_eq!(read(b"ICR"), Err(ReadError::Truncated)); + assert_eq!(read(b"XCRP\x01\0\0\0\0\0\0\0"), Err(ReadError::BadMagic)); + let mut buf = write("{}", &[]); + buf[14] = 1; + assert_eq!(read(&buf), Err(ReadError::NonZeroPadding)); + let mut short = write("{\"a\":1}", &[]); + short.truncate(14); + assert_eq!(read(&short), Err(ReadError::Truncated)); + } +} diff --git a/native/replay/src/decode.rs b/native/replay/src/decode.rs new file mode 100644 index 000000000..376558539 --- /dev/null +++ b/native/replay/src/decode.rs @@ -0,0 +1,244 @@ +//! The walk over a whole replay: chunks -> decompressed DemoFrames -> packets +//! -> vrfkit's sink records, handed to an [`Observer`] packet by packet so no +//! caller holds the million field rows of a match at once. +//! +//! The per-packet pass is vrfkit's (`vendor::pass`); this module owns the +//! chunk loop that vrfkit's CLI keeps in `pass::for_each_chunk` and the +//! driver, because ours decompresses through the Oodle seam, reports +//! progress and can be cancelled. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::sync_channel; +use std::thread; + +use vrf_container::{ + ChunkIterator, ChunkType, Preamble, event_payload_seconds_matches_time, known_event_word_count, + parse_event_chunk, parse_known_event_payload, +}; +use vrf_export::EventRecord; +use vrf_net::pipeline::ReplicationReader; +use vrf_net::stats::NetStats; +use vrf_schema::{FxHashMap, NetGuidCache}; + +use crate::error::{ErrorCode, ReplayError}; +use crate::oodle::{Decompressor, inflate_replay_data}; +use crate::vendor::pass::Pass; +use crate::vendor::sink::{ExportStats, PlayerIdentity, RecordBuffers}; + +/// The caller's progress and cancel cells (the C ABI's two pointers). +#[derive(Default, Clone, Copy)] +pub struct Control<'a> { + /// 0..=10000, hundredths of a percent; relaxed stores. + pub progress: Option<&'a AtomicU32>, + /// Nonzero asks the decode to stop with `cancelled`. + pub cancel: Option<&'a AtomicU32>, +} + +/// Progress is in hundredths of a percent. +pub const PROGRESS_DONE: u32 = 10_000; +/// The walk's share of the progress bar; the analysis takes the rest. +pub const WALK_SHARE: f64 = 0.9; + +impl Control<'_> { + pub fn cancelled(&self) -> bool { + self.cancel.is_some_and(|c| c.load(Ordering::Relaxed) != 0) + } + + pub fn check(&self) -> Result<(), ReplayError> { + if self.cancelled() { + Err(ReplayError::cancelled()) + } else { + Ok(()) + } + } + + /// Report `fraction` (0..1) of the work done. + pub fn report(&self, fraction: f64) { + if let Some(p) = self.progress { + let value = (fraction.clamp(0.0, 1.0) * f64::from(PROGRESS_DONE)).round() as u32; + p.store(value, Ordering::Relaxed); + } + } +} + +/// What the walk hands its consumer. +pub trait Observer { + /// One Event chunk, parsed as vrfkit's driver parses it. + fn on_event(&mut self, event: EventRecord); + /// One packet's records; `cache` is the GUID table as of that packet. + fn on_packet(&mut self, records: &RecordBuffers, cache: &NetGuidCache); +} + +/// What the walk counted, and the state the analysis reads afterwards. +pub struct Walked { + pub cache: NetGuidCache, + pub players: FxHashMap, + pub stats: ExportStats, + pub net: NetStats, + pub replay_data_chunks: u32, + pub event_chunks: u32, + pub unknown_chunks: u32, + pub packets: u32, + pub frames: u32, + /// Event chunks whose payload did not fit the measured layout. + pub event_layout_mismatches: u32, + pub stream_failures: Vec, +} + +/// Walk `data` (the whole file). `transform_branch` picks the payload +/// transform: the header's own branch, except in the guard's tests. +pub fn walk( + data: &[u8], + preamble: &Preamble, + transform_branch: &str, + decompressor: &dyn Decompressor, + control: Control<'_>, + observer: &mut dyn Observer, +) -> Result { + let build = preamble.header.replay_version.branch.as_str(); + let reader = ReplicationReader::new(transform_branch).map_err(|e| { + ReplayError::new(ErrorCode::UnsupportedBuild, e.to_string()).with_build(build) + })?; + if preamble.info.encrypted { + return Err( + ReplayError::new(ErrorCode::UnsupportedBuild, "encrypted replay").with_build(build), + ); + } + let mut pass = Pass::new(transform_branch, preamble.header.flags, reader); + let mut stats = ExportStats::default(); + let compressed = preamble.info.compressed; + let first_chunk = preamble.remaining_offset; + let total = data.len().max(1) as f64; + + let chunks = move || { + let mut iter = ChunkIterator::new(data, first_chunk); + std::iter::from_fn(move || match iter.next_chunk() { + Ok(Some(c)) => { + let end = c.data_offset + c.size_in_bytes as usize; + Some(Ok((c.chunk_type, &data[c.data_offset..end], end))) + } + Ok(None) => None, + Err(e) => { + iter = ChunkIterator::new(&[], 0); + Some(Err(e)) + } + }) + }; + + let mut walked = Walked { + cache: NetGuidCache::new(), + players: FxHashMap::default(), + stats: ExportStats::default(), + net: NetStats::default(), + replay_data_chunks: 0, + event_chunks: 0, + unknown_chunks: 0, + packets: 0, + frames: 0, + event_layout_mismatches: 0, + stream_failures: Vec::new(), + }; + + thread::scope(|scope| -> Result<(), ReplayError> { + // Rendezvous: the helper holds at most one decompressed chunk ahead + // (Oodle is order-free and a fifth of the work in vrfkit's measure). + let (tx, rx) = sync_channel(0); + scope.spawn(move || { + for (_, payload, _) in chunks() + .map_while(Result::ok) + .filter(|(kind, _, _)| *kind == ChunkType::ReplayData) + { + if control.cancelled() { + return; + } + if tx + .send(inflate_replay_data(payload, compressed, decompressor)) + .is_err() + { + return; + } + } + }); + for chunk in chunks() { + control.check()?; + let (kind, payload, end) = + chunk.map_err(|e| crate::header::container_error(&e).with_build(build))?; + match kind { + ChunkType::ReplayData => { + let Ok(inflated) = rx.recv() else { + // The helper stops early only when cancelled. + control.check()?; + return Err(ReplayError::new( + ErrorCode::Corrupt, + "decompression stopped", + )); + }; + let frames = inflated.map_err(|e| e.with_build(build))?; + walked.replay_data_chunks += 1; + let finished = pass + .walk(&frames, &mut stats, |records, cache| { + observer.on_packet(records, cache); + !control.cancelled() + }) + .map_err(|e| { + ReplayError::new(ErrorCode::Corrupt, e.to_string()).with_build(build) + })?; + if !finished { + return Err(ReplayError::cancelled()); + } + } + ChunkType::Event => { + walked.event_chunks += 1; + let event = parse_event(payload, &mut walked.event_layout_mismatches) + .map_err(|e| e.with_build(build))?; + observer.on_event(event); + } + // Checkpoints repeat state the stream already carried. + ChunkType::Checkpoint | ChunkType::Header => {} + ChunkType::Unknown(_) => walked.unknown_chunks += 1, + } + control.report(WALK_SHARE * end as f64 / total); + } + Ok(()) + })?; + + pass.finish(); + observer.on_packet(&pass.buffers, &pass.cache); + walked.packets = pass.packets; + walked.frames = pass.frames; + walked.net = pass.reader.stats().clone(); + walked.stream_failures = pass.channels.stream_failures().to_vec(); + walked.players = pass.channels.players().clone(); + walked.cache = pass.cache; + walked.stats = stats; + Ok(walked) +} + +/// One Event chunk as vrfkit's driver (`write_event`) reads it: the words +/// only when the payload fits the measured layout exactly. +fn parse_event(payload: &[u8], mismatches: &mut u32) -> Result { + let event = parse_event_chunk(payload) + .map_err(|e| ReplayError::new(ErrorCode::Corrupt, format!("event chunk: {e}")))?; + let parsed = known_event_word_count(&event.group).and_then(|_| { + let parsed = parse_known_event_payload(&event.group, event.payload) + .filter(|p| event_payload_seconds_matches_time(event.time1, p.seconds)); + if parsed.is_none() { + *mismatches += 1; + } + parsed + }); + Ok(EventRecord { + id: event.id, + group: event.group, + metadata: event.metadata, + time1: event.time1, + time2: event.time2, + payload_size: event.size_in_bytes, + raw_payload: event.payload.to_vec(), + word0: parsed.as_ref().and_then(|p| p.words.first().copied()), + word1: parsed.as_ref().and_then(|p| p.words.get(1).copied()), + payload_tag: parsed.as_ref().map(|p| p.tag), + payload_name: parsed.as_ref().map(|p| p.name.clone()), + payload_seconds: parsed.as_ref().map(|p| p.seconds), + }) +} diff --git a/native/replay/src/document.rs b/native/replay/src/document.rs new file mode 100644 index 000000000..be7aa2dcf --- /dev/null +++ b/native/replay/src/document.rs @@ -0,0 +1,172 @@ +//! The JSON document of docs/replay-format.md, as serde types. Optional +//! fields are skipped when absent, so "absent" and "null" never disagree. + +use std::collections::BTreeMap; + +use serde::Serialize; + +pub type Vec3 = [f64; 3]; + +/// `[timeMs, health, armor]`. +pub type VitalsRow = (i64, Option, Option); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Document { + pub decoder: Decoder, + #[serde(rename = "match")] + pub match_: Match, + pub players: Vec, + pub rounds: Vec, + pub kills: Vec, + /// subject -> `[timeMs, health, armor]` rows. + pub vitals: BTreeMap>, + pub utility: Vec, + pub casts: Vec, + pub movement: BTreeMap, + pub quality: Quality, +} + +#[derive(Debug, Serialize)] +pub struct Decoder { + pub name: &'static str, + pub version: &'static str, + pub vrfkit: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Match { + pub id: String, + pub map_path: Option, + pub build: String, + pub duration_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub recorded_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub game_mode: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Player { + pub subject: String, + pub agent_id: Option, + pub team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub character_guids: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Round { + pub index: u32, + pub start_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub combat_start_ms: Option, + pub end_ms: i64, + pub attacking_team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub winning_team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_reason: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub plant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub defuse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detonate_ms: Option, + pub economy: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Plant { + pub time_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub site: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Defuse { + pub time_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Economy { + pub subject: String, + pub credits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loadout_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub weapon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub armor: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Kill { + pub time_ms: i64, + pub victim: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub killer: Option, + pub assists: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub damage_source: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Utility { + pub id: u32, + pub class_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + pub spawn_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_ms: Option, + pub position: Vec3, + #[serde(skip_serializing_if = "Option::is_none")] + pub yaw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub points: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Cast { + pub time_ms: i64, + pub subject: String, + /// The replicated `AbilityCastsThisRound[].Slot` value. + pub slot: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub class_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +#[derive(Debug, Serialize, Clone, Copy)] +pub struct MovementSpan { + pub offset: u64, + pub count: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Quality { + pub transform_verified: bool, + pub decode_errors: u64, + pub warnings: Vec, +} diff --git a/native/replay/src/error.rs b/native/replay/src/error.rs new file mode 100644 index 000000000..a05e2118b --- /dev/null +++ b/native/replay/src/error.rs @@ -0,0 +1,64 @@ +//! The error object of docs/replay-format.md ("Errors"). + +use serde::Serialize; + +/// The `code` of an error object, spelled as the contract spells it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ErrorCode { + NotAReplay, + UnsupportedBuild, + UnsupportedCompression, + TransformCheckFailed, + Corrupt, + Io, + Cancelled, +} + +/// Why a probe or decode produced no replay. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReplayError { + pub code: ErrorCode, + pub message: String, + /// The replay's build, when the header got far enough to name it. + #[serde(skip_serializing_if = "Option::is_none")] + pub build: Option, +} + +impl ReplayError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + build: None, + } + } + + #[must_use] + pub fn with_build(mut self, build: &str) -> Self { + self.build = Some(build.to_owned()); + self + } + + pub fn cancelled() -> Self { + Self::new(ErrorCode::Cancelled, "decode cancelled") + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).expect("an error object always serializes") + } +} + +impl std::fmt::Display for ReplayError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for ReplayError {} + +impl From for ReplayError { + fn from(e: std::io::Error) -> Self { + Self::new(ErrorCode::Io, e.to_string()) + } +} diff --git a/native/replay/src/ffi.rs b/native/replay/src/ffi.rs new file mode 100644 index 000000000..d2ffb9d0b --- /dev/null +++ b/native/replay/src/ffi.rs @@ -0,0 +1,156 @@ +//! The C ABI (include/icarus_replay.h). The only `unsafe` in the crate: it +//! reads the caller's path and cells and hands back buffers it allocated. + +use std::ffi::{CStr, c_char}; +use std::path::PathBuf; +use std::sync::atomic::AtomicU32; + +use crate::decode::Control; +use crate::error::{ErrorCode, ReplayError}; + +/// A byte buffer owned by this library until `icarus_replay_free`. When +/// `is_error` is nonzero, `ptr` holds the error object's JSON instead. +#[repr(C)] +pub struct IcarusReplayBuffer { + pub ptr: *mut u8, + pub len: usize, + pub is_error: u32, +} + +impl IcarusReplayBuffer { + fn from_result(result: Result, ReplayError>) -> Self { + let (bytes, is_error) = match result { + Ok(bytes) => (bytes, 0), + Err(error) => (error.to_json().into_bytes(), 1), + }; + // A boxed slice's capacity is its length, which `free` relies on. + let boxed = bytes.into_boxed_slice(); + let len = boxed.len(); + Self { + ptr: Box::into_raw(boxed).cast::(), + len, + is_error, + } + } +} + +/// Run `f`, turning a panic into a `corrupt` error rather than unwinding +/// into the caller's frames. +fn guarded(f: impl FnOnce() -> Result, ReplayError>) -> IcarusReplayBuffer { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + .unwrap_or_else(|_| Err(ReplayError::new(ErrorCode::Corrupt, "the decoder panicked"))); + IcarusReplayBuffer::from_result(result) +} + +/// # Safety +/// `path` is null or a NUL-terminated string valid for the call. +unsafe fn path_from(path: *const c_char) -> Result { + if path.is_null() { + return Err(ReplayError::new(ErrorCode::Io, "no path given")); + } + // SAFETY: non-null and NUL-terminated by the caller's contract. + let bytes = unsafe { CStr::from_ptr(path) }.to_bytes(); + let text = std::str::from_utf8(bytes) + .map_err(|_| ReplayError::new(ErrorCode::Io, "path is not UTF-8"))?; + Ok(PathBuf::from(text)) +} + +/// Header-only probe: JSON, or an error object. Never decompresses. +/// +/// # Safety +/// `path_utf8` is null or a NUL-terminated UTF-8 string valid for the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_probe(path_utf8: *const c_char) -> IcarusReplayBuffer { + // SAFETY: forwarded caller contract. + let path = unsafe { path_from(path_utf8) }; + guarded(|| { + let probe = crate::header::probe(&path?)?; + Ok(serde_json::to_vec(&probe).expect("a probe always serializes")) + }) +} + +/// Full decode to an `.icrp` buffer, or an error object. `progress` receives +/// 0..10000 by relaxed stores; a nonzero `cancel` stops the decode with +/// `cancelled`. Either may be null. +/// +/// # Safety +/// `path_utf8` as for `icarus_replay_probe`. `progress` and `cancel` are null +/// or 4-byte-aligned cells that stay valid until this call returns; other +/// threads may touch them only with atomic operations. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_decode( + path_utf8: *const c_char, + progress: *mut u32, + cancel: *const u32, +) -> IcarusReplayBuffer { + // SAFETY: forwarded caller contract. + let path = unsafe { path_from(path_utf8) }; + // SAFETY: aligned, live for the call, and only touched atomically; AtomicU32 + // has u32's size and alignment. + let progress = unsafe { progress.cast::().as_ref() }; + // SAFETY: as above; only loaded. + let cancel = unsafe { cancel.cast::().as_ref() }; + guarded(|| crate::decode_file(&path?, Control { progress, cancel })) +} + +/// Release a buffer from `icarus_replay_probe` or `icarus_replay_decode`. +/// A null `ptr` is ignored. +/// +/// # Safety +/// `buffer` came from this library and is freed once. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_free(buffer: IcarusReplayBuffer) { + if buffer.ptr.is_null() { + return; + } + // SAFETY: `ptr`/`len` are a boxed slice this library leaked in `from_result`. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(buffer.ptr, buffer.len)) }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(buffer: &IcarusReplayBuffer) -> &[u8] { + // SAFETY: a live buffer from this library. + unsafe { std::slice::from_raw_parts(buffer.ptr, buffer.len) } + } + + #[test] + fn errors_cross_the_abi_as_json_and_free_cleanly() { + let path = c"this/does/not/exist.vrf"; + // SAFETY: a valid C string; null cells. + let buffer = + unsafe { icarus_replay_decode(path.as_ptr(), std::ptr::null_mut(), std::ptr::null()) }; + assert_eq!(buffer.is_error, 1); + let json: serde_json::Value = serde_json::from_slice(read(&buffer)).unwrap(); + assert_eq!(json["code"], "io"); + // SAFETY: from this library, freed once. + unsafe { icarus_replay_free(buffer) }; + } + + #[test] + fn a_null_path_is_an_error_not_a_crash() { + // SAFETY: null is allowed. + let buffer = unsafe { icarus_replay_probe(std::ptr::null()) }; + assert_eq!(buffer.is_error, 1); + // SAFETY: from this library, freed once. + unsafe { icarus_replay_free(buffer) }; + // SAFETY: a null buffer is ignored. + unsafe { + icarus_replay_free(IcarusReplayBuffer { + ptr: std::ptr::null_mut(), + len: 0, + is_error: 0, + }) + }; + } + + #[test] + fn an_empty_success_buffer_round_trips() { + let buffer = IcarusReplayBuffer::from_result(Ok(Vec::new())); + assert_eq!((buffer.len, buffer.is_error), (0, 0)); + // SAFETY: from this library, freed once. + unsafe { icarus_replay_free(buffer) }; + } +} diff --git a/native/replay/src/fieldpath.rs b/native/replay/src/fieldpath.rs new file mode 100644 index 000000000..2555b323d --- /dev/null +++ b/native/replay/src/fieldpath.rs @@ -0,0 +1,100 @@ +//! vrfkit's field names for array members, `Rounds[2].Reports[0].Interactions[1].ParticipantSubject` +//! or `AbilityCastsThisRound[0].CastTime_4_5AE288704801A9B74D6D159DFC2BD147`, +//! split into segments with the Blueprint member suffix (`__<32 hex>`) dropped. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Segment<'a> { + pub base: &'a str, + pub index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldPath<'a> { + pub segments: Vec>, +} + +impl<'a> FieldPath<'a> { + pub fn parse(name: &'a str) -> Self { + let segments = name + .split('.') + .map(|part| { + let (head, index) = match part.find('[') { + Some(open) if part.ends_with(']') => { + (&part[..open], part[open + 1..part.len() - 1].parse().ok()) + } + _ => (part, None), + }; + Segment { + base: strip_blueprint_suffix(head), + index, + } + }) + .collect(); + Self { segments } + } + + /// The last segment's base name. + pub fn leaf(&self) -> &'a str { + self.segments.last().map_or("", |s| s.base) + } + + /// The indices of every segment but the last, in order. + pub fn indices(&self) -> Vec> { + self.segments[..self.segments.len().saturating_sub(1)] + .iter() + .map(|s| s.index) + .collect() + } +} + +/// `CastTime_4_5AE288704801A9B74D6D159DFC2BD147` -> `CastTime`. +fn strip_blueprint_suffix(name: &str) -> &str { + let Some((head, hex)) = name.rsplit_once('_') else { + return name; + }; + if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return name; + } + match head.rsplit_once('_') { + Some((base, n)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, + _ => name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blueprint_members_and_indices_split() { + let p = FieldPath::parse( + "AbilityCastsThisRound[3].CastTime_4_5AE288704801A9B74D6D159DFC2BD147", + ); + assert_eq!( + p.segments[0], + Segment { + base: "AbilityCastsThisRound", + index: Some(3) + } + ); + assert_eq!(p.leaf(), "CastTime"); + assert_eq!(p.indices(), vec![Some(3)]); + } + + #[test] + fn plain_names_are_untouched() { + let p = FieldPath::parse("MulticastNotifyDamage_Point.LifeChangeEvents[1].LifeResult"); + assert_eq!(p.segments[0].base, "MulticastNotifyDamage_Point"); + assert_eq!( + p.segments[1], + Segment { + base: "LifeChangeEvents", + index: Some(1) + } + ); + assert_eq!(p.leaf(), "LifeResult"); + assert_eq!(FieldPath::parse("Money").leaf(), "Money"); + // A trailing `_` alone is part of the name. + assert_eq!(FieldPath::parse("MyEquippable_0").leaf(), "MyEquippable_0"); + } +} diff --git a/native/replay/src/guard.rs b/native/replay/src/guard.rs new file mode 100644 index 000000000..0993ab9cf --- /dev/null +++ b/native/replay/src/guard.rs @@ -0,0 +1,127 @@ +//! The transform guard. vrfkit refuses a build it has no transform for, but a +//! registered transform that is wrong for this file (a mislabelled build, a +//! hotfix that rotated the transform) decodes into plausible noise. Decoded +//! replication payloads are mostly small integers, flags and padding, so +//! zeros dominate them; noise has about 1/256 zero bytes and half its bits +//! set. The guard samples the start of every decoded field payload. + +/// Bytes taken from the front of each field payload. +const PREFIX_BYTES: usize = 8; +/// Stop sampling after this many bytes: enough for a stable share. +const SAMPLE_LIMIT: u64 = 4 << 20; + +#[derive(Debug, Default, Clone)] +pub struct GuardSample { + pub bytes: u64, + pub zero_bytes: u64, + pub set_bits: u64, + /// Field payloads sampled. + pub payloads: u64, +} + +/// What the guard concluded. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GuardVerdict { + pub zero_share: f64, + pub bit_density: f64, + pub verified: bool, +} + +/// The 7 replays of the corpus test measure 27.8-32.8% zero bytes over field +/// prefixes; read with another build's transform, 0.21-0.23%. +pub const MIN_ZERO_SHARE: f64 = 0.10; +/// The same replays: 24.1-26.9% of bits set; with the wrong transform, 51%. +pub const MAX_BIT_DENSITY: f64 = 0.35; +/// Fewer sampled bytes than this proves nothing either way. +pub const MIN_SAMPLE_BYTES: u64 = 4096; + +impl GuardSample { + pub fn add(&mut self, raw: &[u8], bit_count: u32) { + if self.bytes >= SAMPLE_LIMIT { + return; + } + // Whole bytes only: a trailing partial byte is zero-padded by the + // exporter and would bias the share upwards. + let whole = (bit_count / 8) as usize; + let prefix = &raw[..raw.len().min(whole).min(PREFIX_BYTES)]; + if prefix.is_empty() { + return; + } + self.payloads += 1; + self.bytes += prefix.len() as u64; + for &b in prefix { + self.zero_bytes += u64::from(b == 0); + self.set_bits += u64::from(b.count_ones()); + } + } + + pub fn verdict(&self) -> GuardVerdict { + let bytes = self.bytes.max(1) as f64; + let zero_share = self.zero_bytes as f64 / bytes; + let bit_density = self.set_bits as f64 / (bytes * 8.0); + GuardVerdict { + zero_share, + bit_density, + verified: self.bytes >= MIN_SAMPLE_BYTES + && zero_share >= MIN_ZERO_SHARE + && bit_density <= MAX_BIT_DENSITY, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// xorshift: deterministic noise standing in for a wrong transform. + fn noise(n: usize) -> Vec { + let mut x: u32 = 0x9E37_79B9; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() + } + + #[test] + fn noise_fails_the_guard() { + let mut sample = GuardSample::default(); + for chunk in noise(64 * 1024).chunks(8) { + sample.add(chunk, 64); + } + let v = sample.verdict(); + assert!(!v.verified, "{v:?}"); + assert!( + v.zero_share < 0.01 && (0.45..0.55).contains(&v.bit_density), + "{v:?}" + ); + } + + #[test] + fn payload_shaped_data_passes() { + // Small little-endian integers and flags: what replicated fields hold. + let mut sample = GuardSample::default(); + for i in 0..8192u32 { + let value = (i % 300).to_le_bytes(); + sample.add(&[value[0], value[1], 0, 0, 1, 0, 0, 0], 64); + } + assert!(sample.verdict().verified, "{:?}", sample.verdict()); + } + + #[test] + fn too_small_a_sample_proves_nothing() { + let mut sample = GuardSample::default(); + sample.add(&[0; 8], 64); + assert!(!sample.verdict().verified); + } + + #[test] + fn partial_trailing_bytes_are_not_sampled() { + let mut sample = GuardSample::default(); + sample.add(&[0xFF, 0x01], 9); + assert_eq!(sample.bytes, 1); + } +} diff --git a/native/replay/src/header.rs b/native/replay/src/header.rs new file mode 100644 index 000000000..1096f0d96 --- /dev/null +++ b/native/replay/src/header.rs @@ -0,0 +1,230 @@ +//! What the replay info and Header chunk say, read without decompressing a +//! byte of the stream: the probe's whole answer, and the decode's `match` +//! block and player agents. + +use std::io::Read; +use std::path::Path; + +use serde::Serialize; +use vrf_container::{ContainerError, Preamble, parse_preamble}; +use vrf_transform::TransformVersion; + +use crate::error::{ErrorCode, ReplayError}; + +/// Changes whenever the decoded output could: our version plus vrfkit's tag. +/// The Dart cache keys decoded replays by it. +pub const DECODER_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+vrfkit.0.2.5"); +pub const VRFKIT_VERSION: &str = "0.2.5"; + +/// The facts the probe returns (docs/replay-format.md, "Probe"). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Probe { + pub id: String, + pub map_path: Option, + pub build: String, + pub duration_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub recorded_at: Option, + pub supported: bool, + pub decoder_version: &'static str, + pub players: Vec, +} + +/// One `playerLoadouts` entry of the header's game-specific data. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HeaderPlayer { + pub subject: String, + pub agent_id: Option, +} + +impl Probe { + pub fn from_preamble(preamble: &Preamble) -> Self { + let build = preamble.header.replay_version.branch.clone(); + Self { + id: preamble.info.friendly_name.clone(), + map_path: preamble + .header + .level_names_and_times + .first() + .map(|(name, _)| name.clone()), + supported: TransformVersion::from_branch(&build).is_some(), + build, + duration_ms: i64::from(preamble.info.length_in_ms), + recorded_at: ticks_to_iso8601(preamble.info.timestamp), + decoder_version: DECODER_VERSION, + players: header_players(&preamble.header.game_specific_data), + } + } +} + +/// `playerLoadouts` from the game-specific data entries; entries that are not +/// JSON or carry no loadouts contribute nothing. +pub fn header_players(game_specific_data: &[String]) -> Vec { + let mut players = Vec::new(); + for entry in game_specific_data { + let Ok(json) = serde_json::from_str::(entry) else { + continue; + }; + for loadout in json["playerLoadouts"].as_array().into_iter().flatten() { + if let Some(subject) = loadout["subject"].as_str() { + players.push(HeaderPlayer { + subject: subject.to_ascii_lowercase(), + agent_id: loadout["characterId"].as_str().map(str::to_ascii_lowercase), + }); + } + } + } + players +} + +/// Unreal `FDateTime` ticks (100 ns since 0001-01-01) as an ISO-8601 second. +/// The wire carries no zone; the contract labels it UTC. +pub fn ticks_to_iso8601(ticks: i64) -> Option { + const TICKS_PER_SECOND: i64 = 10_000_000; + /// Seconds from 0001-01-01 to 1970-01-01. + const UNIX_EPOCH_SECONDS: i64 = 62_135_596_800; + if ticks <= 0 { + return None; + } + let unix = ticks / TICKS_PER_SECOND - UNIX_EPOCH_SECONDS; + let (days, secs) = (unix.div_euclid(86_400), unix.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + if !(1..=9999).contains(&y) { + return None; + } + Some(format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + secs / 3600, + secs / 60 % 60, + secs % 60 + )) +} + +/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's +/// `civil_from_days`). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + +/// Map a container failure onto the contract's codes: a file that is not a +/// replay at all, versus a replay that is damaged. +pub fn container_error(error: &ContainerError) -> ReplayError { + let code = match error { + ContainerError::FileMagicMismatch { .. } + | ContainerError::NetworkMagicMismatch { .. } + | ContainerError::UnsupportedFileVersion { .. } + | ContainerError::MissingHeaderChunk + | ContainerError::DataBeforeHeader + | ContainerError::ChunkBeforeHeader { .. } => ErrorCode::NotAReplay, + ContainerError::EncryptedWithoutKey | ContainerError::EncryptedNotSupported => { + ErrorCode::UnsupportedBuild + } + ContainerError::OodleUnsupported { .. } => ErrorCode::UnsupportedCompression, + _ => ErrorCode::Corrupt, + }; + ReplayError::new(code, error.to_string()) +} + +/// Parse the preamble from the start of `path`, reading only as much of the +/// file as the Header chunk needs. +pub fn read_preamble(path: &Path) -> Result { + let mut file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + let mut data = Vec::new(); + let mut want: u64 = 64 * 1024; + loop { + let target = want.min(len) as usize; + if data.len() < target { + let mut more = vec![0; target - data.len()]; + file.read_exact(&mut more)?; + data.extend(more); + } + match parse_preamble(&data) { + Ok(preamble) => return Ok(preamble), + Err(ContainerError::Truncated { .. }) if (data.len() as u64) < len => want *= 4, + Err(ContainerError::InvalidChunkSize { .. }) if (data.len() as u64) < len => { + // A chunk that runs past the bytes read so far reads as an + // invalid size until the rest of it is loaded. + want *= 4; + } + Err(e) => return Err(container_error(&e)), + } + } +} + +/// The probe for one file. +pub fn probe(path: &Path) -> Result { + Ok(Probe::from_preamble(&read_preamble(path)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ticks_read_as_utc_seconds() { + // 2026-07-08 00:35:45.074, from dc078274's header. + assert_eq!( + ticks_to_iso8601(639_190_677_450_740_000).as_deref(), + Some("2026-07-08T00:35:45Z") + ); + assert_eq!( + ticks_to_iso8601(621_355_968_000_000_000).as_deref(), + Some("1970-01-01T00:00:00Z") + ); + assert_eq!(ticks_to_iso8601(0), None); + } + + #[test] + fn decoder_version_names_both_versions() { + assert_eq!(DECODER_VERSION, "0.1.0+vrfkit.0.2.5"); + } + + #[test] + fn loadouts_come_from_any_game_data_entry() { + let data = vec![ + "{\"serializedVersion\": 2}".to_owned(), + "not json".to_owned(), + r#"{"playerLoadouts":[{"subject":"AB-1","characterId":"CD-2"},{"subject":"x"}]}"# + .to_owned(), + ]; + assert_eq!( + header_players(&data), + vec![ + HeaderPlayer { + subject: "ab-1".into(), + agent_id: Some("cd-2".into()) + }, + HeaderPlayer { + subject: "x".into(), + agent_id: None + }, + ] + ); + } + + #[test] + fn a_file_that_is_not_a_replay_says_so() { + let path = std::env::temp_dir().join(format!("not-a-replay-{}.vrf", std::process::id())); + std::fs::write(&path, b"hello, this is not a replay file at all").unwrap(); + let err = probe(&path).unwrap_err(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(err.code, ErrorCode::NotAReplay, "{err}"); + } + + #[test] + fn a_missing_file_is_io() { + let err = probe(Path::new("this/does/not/exist.vrf")).unwrap_err(); + assert_eq!(err.code, ErrorCode::Io); + } +} diff --git a/native/replay/src/lib.rs b/native/replay/src/lib.rs new file mode 100644 index 000000000..b662b2e5f --- /dev/null +++ b/native/replay/src/lib.rs @@ -0,0 +1,115 @@ +//! Decodes a Valorant `.vrf` replay into the buffer docs/replay-format.md +//! describes. Built on vrfkit (docs/adr/0006-replay-decoder.md). + +mod analysis; +mod collect; +pub mod container; +pub mod decode; +pub mod document; +pub mod error; +mod ffi; +mod fieldpath; +pub mod guard; +pub mod header; +pub mod oodle; +mod vendor; + +use std::path::Path; + +pub use error::{ErrorCode, ReplayError}; +pub use vendor::sink::RecordBuffers; + +use decode::Control; +use oodle::Decompressor; + +/// Decode the replay at `path` into an `.icrp` buffer, decompressing with +/// this build's Oodle decoder. +pub fn decode_file(path: &Path, control: Control<'_>) -> Result, ReplayError> { + decode_file_with(path, oodle::default_decompressor().as_ref(), control) +} + +/// [`decode_file`] with an explicit decompressor. +pub fn decode_file_with( + path: &Path, + decompressor: &dyn Decompressor, + control: Control<'_>, +) -> Result, ReplayError> { + // The header alone settles a build we cannot read, before 100 MB of reading. + let preamble = header::read_preamble(path)?; + let build = preamble.header.replay_version.branch.clone(); + if !header::Probe::from_preamble(&preamble).supported { + return Err(ReplayError::new( + ErrorCode::UnsupportedBuild, + format!("no payload transform is registered for {build}"), + ) + .with_build(&build)); + } + let named = |e: ReplayError| match e.build { + Some(_) => e, + None => e.with_build(&build), + }; + control.check().map_err(named)?; + let data = std::fs::read(path).map_err(|e| named(e.into()))?; + decode_bytes(&data, &build, decompressor, control).map_err(named) +} + +/// What the transform guard measures on `data` read with `transform_branch`'s +/// transform: the walk without the analysis, for tests and diagnostics. +pub fn measure_guard( + data: &[u8], + transform_branch: &str, + decompressor: &dyn Decompressor, +) -> Result<(guard::GuardVerdict, guard::GuardSample), ReplayError> { + let preamble = vrf_container::parse_preamble(data).map_err(|e| header::container_error(&e))?; + let mut collector = collect::Collector::default(); + decode::walk( + data, + &preamble, + transform_branch, + decompressor, + Control::default(), + &mut collector, + )?; + Ok((collector.guard.verdict(), collector.guard)) +} + +/// Decode a whole file already in memory, reading its payloads with the +/// transform registered for `transform_branch` (the header's own branch, +/// except in the guard's tests). +pub fn decode_bytes( + data: &[u8], + transform_branch: &str, + decompressor: &dyn Decompressor, + control: Control<'_>, +) -> Result, ReplayError> { + let preamble = vrf_container::parse_preamble(data).map_err(|e| header::container_error(&e))?; + let build = preamble.header.replay_version.branch.clone(); + let mut collector = collect::Collector::default(); + let walked = decode::walk( + data, + &preamble, + transform_branch, + decompressor, + control, + &mut collector, + )?; + let verdict = collector.guard.verdict(); + if !verdict.verified { + return Err(ReplayError::new( + ErrorCode::TransformCheckFailed, + format!( + "decoded payloads look like noise: {:.2}% zero bytes, {:.1}% bits set over {} bytes", + verdict.zero_share * 100.0, + verdict.bit_density * 100.0, + collector.guard.bytes + ), + ) + .with_build(&build)); + } + control.check()?; + let analysed = analysis::analyse(&preamble, &collector, &walked, verdict); + let json = serde_json::to_string(&analysed.document) + .map_err(|e| ReplayError::new(ErrorCode::Corrupt, format!("document: {e}")))?; + control.report(1.0); + Ok(container::write(&json, &analysed.blob)) +} diff --git a/native/replay/src/oodle.rs b/native/replay/src/oodle.rs new file mode 100644 index 000000000..ff55b2232 --- /dev/null +++ b/native/replay/src/oodle.rs @@ -0,0 +1,304 @@ +//! The Oodle seam. Every compressed archive in a replay goes through one +//! [`Decompressor`]; nothing else in the crate knows how Oodle works. +//! +//! The production decoder is our own clean-room Selkie decoder, which fills +//! this seam when it lands. Until then a build has either no decoder +//! ([`Unavailable`], which reports `unsupportedCompression`) or, with the +//! `dev-vectors` feature, [`VectorDecompressor`], which looks each archive up +//! in a directory of recorded input/output pairs. + +use vrf_container::parse_replay_data_meta; + +use crate::error::{ErrorCode, ReplayError}; + +/// Inflates one Oodle archive body (the bytes after its 8-byte header) to +/// exactly `decompressed_size` bytes. +pub trait Decompressor: Send + Sync { + fn decompress(&self, input: &[u8], decompressed_size: usize) -> Result, ReplayError>; +} + +/// No decoder in this build: every compressed archive is refused. +pub struct Unavailable; + +impl Decompressor for Unavailable { + fn decompress(&self, _input: &[u8], decompressed_size: usize) -> Result, ReplayError> { + Err(ReplayError::new( + ErrorCode::UnsupportedCompression, + format!( + "this build has no Oodle decoder (a {decompressed_size}-byte archive needs one)" + ), + )) + } +} + +/// The decompressor this build ships with: the dev vectors when the feature +/// is on and `ICARUS_OODLE_VECTORS` names a directory, otherwise none. +pub fn default_decompressor() -> Box { + #[cfg(feature = "dev-vectors")] + if let Some(dir) = std::env::var_os("ICARUS_OODLE_VECTORS") { + return match VectorDecompressor::open(std::path::Path::new(&dir)) { + Ok(vectors) => Box::new(vectors), + Err(error) => Box::new(Broken(error)), + }; + } + Box::new(Unavailable) +} + +/// A vectors directory that could not be indexed: every archive reports why. +#[cfg(feature = "dev-vectors")] +struct Broken(ReplayError); + +#[cfg(feature = "dev-vectors")] +impl Decompressor for Broken { + fn decompress(&self, _input: &[u8], _size: usize) -> Result, ReplayError> { + Err(self.0.clone()) + } +} + +/// Bytes of ReplayData prologue ahead of the archive: Time1, Time2, +/// SizeInBytes, MemorySizeInBytes. +const REPLAY_DATA_PROLOGUE_BYTES: usize = 16; +/// Bytes of Oodle archive header: decompressed size, then compressed size. +const OODLE_HEADER_BYTES: usize = 8; + +/// Decompress one ReplayData chunk payload (from Time1 on). The framing +/// checks mirror vrfkit's `decompress_replay_data_with_trailing`, which we +/// cannot call with its `oodle` feature off; only the codec is ours. +pub fn inflate_replay_data( + payload: &[u8], + compressed: bool, + decompressor: &dyn Decompressor, +) -> Result, ReplayError> { + let corrupt = |m: String| ReplayError::new(ErrorCode::Corrupt, m); + let meta = parse_replay_data_meta(payload).map_err(|e| corrupt(e.to_string()))?; + let data = &payload[REPLAY_DATA_PROLOGUE_BYTES..]; + let declared = usize::try_from(meta.size_in_bytes) + .map_err(|_| corrupt(format!("negative ReplayData size {}", meta.size_in_bytes)))?; + let memory = meta.memory_size_in_bytes as usize; // checked non-negative by vrf-container + if data.len() < declared { + return Err(corrupt(format!( + "ReplayData declares {declared} bytes, {} remain", + data.len() + ))); + } + if !compressed { + if declared != memory { + return Err(corrupt(format!( + "uncompressed ReplayData of {declared} bytes declares {memory} in memory" + ))); + } + return Ok(data[..declared].to_vec()); + } + if declared < OODLE_HEADER_BYTES { + return Err(corrupt(format!( + "Oodle archive of {declared} bytes has no header" + ))); + } + let le = |at: usize| u32::from_le_bytes(data[at..at + 4].try_into().expect("4 bytes")); + let (archive_size, archive_compressed) = (le(0) as usize, le(4) as usize); + if archive_size != memory { + return Err(corrupt(format!( + "Oodle archive inflates to {archive_size} bytes, ReplayData says {memory}" + ))); + } + if archive_compressed != declared - OODLE_HEADER_BYTES { + return Err(corrupt(format!( + "Oodle archive holds {archive_compressed} bytes, ReplayData says {}", + declared - OODLE_HEADER_BYTES + ))); + } + let plain = decompressor.decompress(&data[OODLE_HEADER_BYTES..declared], archive_size)?; + if plain.len() != archive_size { + return Err(corrupt(format!( + "Oodle produced {} bytes, the archive declares {archive_size}", + plain.len() + ))); + } + Ok(plain) +} + +/// Dev-only: recorded archive pairs, keyed by the sha256 of the input bytes. +/// +/// The directory holds `.in` / `.out` pairs. With a `manifest.json` +/// (`archives[].input.sha256`, `.input.file`, `.output.file`) the index is +/// read from it; otherwise every `*.in` is hashed once at open. +#[cfg(feature = "dev-vectors")] +pub struct VectorDecompressor { + dir: std::path::PathBuf, + /// Input sha256 (lowercase hex) -> output file name. + outputs: std::collections::HashMap, +} + +#[cfg(feature = "dev-vectors")] +impl VectorDecompressor { + pub fn open(dir: &std::path::Path) -> Result { + let unavailable = |m: String| ReplayError::new(ErrorCode::UnsupportedCompression, m); + let mut outputs = std::collections::HashMap::new(); + let manifest = dir.join("manifest.json"); + if manifest.is_file() { + let text = std::fs::read_to_string(&manifest)?; + let json: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| unavailable(format!("{}: {e}", manifest.display())))?; + for archive in json["archives"].as_array().into_iter().flatten() { + let (Some(sha), Some(out)) = ( + archive["input"]["sha256"].as_str(), + archive["output"]["file"].as_str(), + ) else { + continue; + }; + outputs.insert(sha.to_ascii_lowercase(), out.to_owned()); + } + } else { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().is_some_and(|e| e == "in") { + let out = path.with_extension("out"); + let name = out + .file_name() + .expect("a file") + .to_string_lossy() + .into_owned(); + outputs.insert(sha256_hex(&std::fs::read(&path)?), name); + } + } + } + if outputs.is_empty() { + return Err(unavailable(format!( + "no Oodle vectors in {}", + dir.display() + ))); + } + Ok(Self { + dir: dir.to_owned(), + outputs, + }) + } +} + +#[cfg(feature = "dev-vectors")] +impl Decompressor for VectorDecompressor { + fn decompress(&self, input: &[u8], decompressed_size: usize) -> Result, ReplayError> { + let sha = sha256_hex(input); + let Some(name) = self.outputs.get(&sha) else { + return Err(ReplayError::new( + ErrorCode::UnsupportedCompression, + format!("no Oodle vector for archive {sha} ({} bytes)", input.len()), + )); + }; + let plain = std::fs::read(self.dir.join(name))?; + if plain.len() != decompressed_size { + return Err(ReplayError::new( + ErrorCode::Corrupt, + format!( + "vector {name} holds {} bytes, the archive declares {decompressed_size}", + plain.len() + ), + )); + } + Ok(plain) + } +} + +#[cfg(feature = "dev-vectors")] +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + let mut hex = String::with_capacity(64); + for byte in digest { + hex.push_str(&format!("{byte:02x}")); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A ReplayData payload around an Oodle archive of `body`, declaring + /// `memory` decompressed bytes. + fn replay_data(body: &[u8], memory: u32) -> Vec { + let size = (body.len() + OODLE_HEADER_BYTES) as u32; + let mut buf = Vec::new(); + for word in [0, 1000, size, memory, memory, body.len() as u32] { + buf.extend(word.to_le_bytes()); + } + buf.extend(body); + buf + } + + struct Fixed(Vec); + impl Decompressor for Fixed { + fn decompress(&self, _: &[u8], _: usize) -> Result, ReplayError> { + Ok(self.0.clone()) + } + } + + #[test] + fn no_decoder_reports_unsupported_compression_not_a_panic() { + let err = inflate_replay_data(&replay_data(&[1, 2, 3], 9), true, &Unavailable).unwrap_err(); + assert_eq!(err.code, ErrorCode::UnsupportedCompression); + } + + #[test] + fn the_codec_sees_exactly_the_archive_body() { + struct Echo; + impl Decompressor for Echo { + fn decompress(&self, input: &[u8], size: usize) -> Result, ReplayError> { + let mut out = input.to_vec(); + out.resize(size, 0); + Ok(out) + } + } + let plain = inflate_replay_data(&replay_data(&[7, 8, 9], 4), true, &Echo).unwrap(); + assert_eq!(plain, [7, 8, 9, 0]); + } + + #[test] + fn a_short_codec_output_is_corrupt() { + let err = inflate_replay_data(&replay_data(&[1], 4), true, &Fixed(vec![0; 3])).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } + + #[test] + fn archive_header_disagreeing_with_the_prologue_is_corrupt() { + let mut data = replay_data(&[1, 2], 4); + data[16] = 5; // archive decompressed size + let err = inflate_replay_data(&data, true, &Fixed(vec![0; 4])).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } + + #[test] + fn a_truncated_chunk_is_corrupt() { + let mut data = replay_data(&[1, 2, 3, 4], 4); + data.truncate(data.len() - 2); + let err = inflate_replay_data(&data, true, &Fixed(vec![0; 4])).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } + + #[test] + fn uncompressed_data_passes_through() { + let mut buf = Vec::new(); + for word in [0u32, 10, 3, 3] { + buf.extend(word.to_le_bytes()); + } + buf.extend([4, 5, 6]); + assert_eq!( + inflate_replay_data(&buf, false, &Unavailable).unwrap(), + [4, 5, 6] + ); + } + + #[cfg(feature = "dev-vectors")] + #[test] + fn vectors_are_found_by_input_hash() { + let dir = std::env::temp_dir().join(format!("icarus-vectors-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.in"), [1, 2, 3]).unwrap(); + std::fs::write(dir.join("a.out"), [9, 9, 9, 9]).unwrap(); + let vectors = VectorDecompressor::open(&dir).unwrap(); + assert_eq!(vectors.decompress(&[1, 2, 3], 4).unwrap(), [9, 9, 9, 9]); + let miss = vectors.decompress(&[1, 2], 4).unwrap_err(); + assert_eq!(miss.code, ErrorCode::UnsupportedCompression); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/native/replay/src/vendor/mod.rs b/native/replay/src/vendor/mod.rs new file mode 100644 index 000000000..8344a8d3f --- /dev/null +++ b/native/replay/src/vendor/mod.rs @@ -0,0 +1,17 @@ +//! Code vendored from vrfkit v0.2.5 (c4d6cc0, Apache-2.0; NOTICE.md), kept +//! apart from ours so a vrfkit bump is a readable diff: re-copy the source +//! files over these and re-apply the edits listed here. +//! +//! - `sink/*.rs`: `crates/vrfkit/src/sink/`, verbatim except `crate::sink::` +//! paths spelled `crate::vendor::sink::` and the CLI's `export` feature gates +//! removed (we always want `ChannelState::players`). +//! - `pass.rs`: `crates/vrfkit/src/pass.rs`, cut to the per-packet walk; the +//! chunk loop, checkpoint scope and helper thread are ours (`crate::decode`). +//! +//! Lints are relaxed here only: the vendored code builds under vrfkit's own +//! toolchain policy, not ours. + +#![allow(dead_code, unused_imports, clippy::all, clippy::pedantic)] + +pub mod pass; +pub mod sink; diff --git a/native/replay/src/vendor/pass.rs b/native/replay/src/vendor/pass.rs new file mode 100644 index 000000000..deaeebff9 --- /dev/null +++ b/native/replay/src/vendor/pass.rs @@ -0,0 +1,98 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/pass.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: `Replay`, `for_each_chunk` and the +// checkpoint block scope are dropped (crate::decode walks the chunks), and the +// CLI error type is replaced by `vrf_frame::FrameError`. + +//! The decode pass every subcommand runs: `validate`, `diag` and `export`, over +//! the ReplayData stream and over each Checkpoint snapshot. + +use vrf_frame::{FrameError, FrameSkips, walk_demo_frames}; +use vrf_net::pipeline::ReplicationReader; +use vrf_schema::NetGuidCache; + +use super::sink::{ChannelState, ExportSink, ExportStats, RecordBuffers}; + +/// One replication stream -- the ReplayData stream, or one checkpoint +/// snapshot -- and what walking it counted. +pub(crate) struct Pass<'a> { + branch: &'a str, + flags: u32, + pub cache: NetGuidCache, + pub reader: ReplicationReader, + pub channels: ChannelState, + pub buffers: RecordBuffers, + /// Packets walked, and so the next packet's id. + pub packets: u32, + pub frames: u32, + pub frame_skips: FrameSkips, + pub non_finite_frame_times: u64, +} + +impl<'a> Pass<'a> { + /// `reader` is built by the caller (`ReplicationReader::new(branch)`), so an + /// unknown branch is the caller's error to name. + pub fn new(branch: &'a str, flags: u32, reader: ReplicationReader) -> Self { + Self { + branch, + flags, + cache: NetGuidCache::new(), + reader, + channels: ChannelState::default(), + buffers: RecordBuffers::default(), + packets: 0, + frames: 0, + frame_skips: FrameSkips::default(), + non_finite_frame_times: 0, + } + } + + /// Read every packet in `frames` through a fresh sink counting into + /// `stats`, then hand its rows to `drain`. Once `drain` returns `false` + /// later packets are skipped (the frame callback cannot stop the walk). + pub fn walk( + &mut self, + frames: &[u8], + stats: &mut ExportStats, + mut drain: impl FnMut(&mut RecordBuffers, &NetGuidCache) -> bool, + ) -> Result { + let branch = self.branch; + let Self { + cache, + reader, + channels, + buffers, + packets, + .. + } = self; + let mut stopped = false; + let walk = walk_demo_frames(frames, self.flags, cache, |pkt, cache| { + if stopped { + return; + } + let mut packet = ExportSink::new(cache, channels, buffers); + packet.enable_measured_array_routes(branch); + packet.time_ms = pkt.time_ms; + packet.packet_id = *packets; + // Lent, not folded: the totals ride in the sink and come back. + std::mem::swap(&mut packet.stats, stats); + reader.process_packet(pkt.data, *packets as i32, &mut packet); + std::mem::swap(&mut packet.stats, stats); + *packets += 1; + if !drain(buffers, cache) { + stopped = true; + } + })?; + self.frames += walk.frames; + self.frame_skips.absorb(walk.skipped); + self.non_finite_frame_times += u64::from(walk.non_finite_times); + Ok(!stopped) + } + + /// End of stream: every bunch still in partial reassembly is counted and + /// becomes a `buffers.partials` row. Until the stream stops, an + /// unfinished partial cannot be told from one in flight. + pub fn finish(&mut self) { + let mut sink = ExportSink::new(&mut self.cache, &mut self.channels, &mut self.buffers); + self.reader.finish_with_sink(&mut sink); + } +} diff --git a/native/replay/src/vendor/sink/blobs.rs b/native/replay/src/vendor/sink/blobs.rs new file mode 100644 index 000000000..38ad8b4c6 --- /dev/null +++ b/native/replay/src/vendor/sink/blobs.rs @@ -0,0 +1,2424 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/blobs.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! Additive decoders for two payload shapes the field stream hands over whole: +//! the parent row keeps its `raw_bits` and is emitted either way, these only +//! add rows, and a decoder that fails leaves the export as it would have been. +//! +//! - **Flattened arrays.** UE flattens a `TArray` element's members onto +//! consecutive handles of the enclosing group, whose net field exports name +//! them; `decode_struct_array` walks that, and this module types the leaves. +//! - **Struct blobs.** `RoundResults`, `TeamEconomy` and `RoundInfos`, opaque +//! to the overlay, have dedicated decoders in `vrf-decode`. + +use smallvec::SmallVec; +use vrf_bitio::BitReader; +use vrf_decode::{ + ABILITY_CASTS_SCHEMA, ArrayDecodeStats, COMBAT_ROUNDS_SCHEMA, EffectArrayKind, FieldType, + FlattenedField, structs, +}; +use vrf_schema::NetGuidCache; + +use super::intern::put; +use super::{ExportSink, FieldValues, MeasuredArrayRoute, TABLE}; + +/// The four typed columns a decoded value lands in. At most one is ever +/// populated; `vrf_export`'s crate doc ("sparse value columns vs. Union") says +/// why this is four nullable columns rather than a union. +type DecodedColumns = (Option, Option, Option, Option); + +/// What the replay declares at `handle`. +fn declared_at(slots: &[Option], handle: u32) -> Option { + slots.get(handle as usize).copied().flatten() +} + +/// How a measured-route leaf is typed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Leaf { + Field(FieldType), + /// KillData `WeaponTheme`: an FString that must carry its null terminator. + WeaponTheme, + /// An `FEffectData*` array, as the RPC parameters of the same name. + Effect(EffectArrayKind), + /// A raw container whose own array is typed by [`NESTED_RULES`]. + Nested, +} + +/// What the overlay may say about a leaf a rule types. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Overlay { + NoEntry, + NoneOrSame, + SameType, + /// Exactly `Raw`: a descriptor kept raw in the table that the rule reads. + RawEntry, +} + +/// `(route, handle, declared name, declared checksum, leaf, overlay, widths)`. +/// A leaf is typed only when every column matches; `None` and `&[]` leave that +/// column unchecked. Anything else stays an exact raw child. +type LeafRule = ( + MeasuredArrayRoute, + u32, + Option<&'static str>, + Option, + Leaf, + Overlay, + &'static [u32], +); + +/// The leaf windows the measured routes type: wire types measured across the +/// corpus (the nested and KillData identities with exact consumption on all +/// 714 replays), not the values' meaning or units. +#[rustfmt::skip] +const LEAF_RULES: &[LeafRule] = { + use FieldType::{Bool, Byte, EnumByte, FName, FTextTree, Float, Int32, Int64, ObjectNetGuid, UInt32, VectorDouble}; + use EffectArrayKind::{Float as Floats, Object as Objects}; + use Leaf::{Effect, Field, Nested, WeaponTheme}; + use MeasuredArrayRoute::*; + use Overlay::*; + &[ + (AllPlayersObfuscatedPlayerInformation, 49, None, None, Field(Bool), SameType, &[]), + (AllPlayersObfuscatedPlayerInformation, 50, None, None, Field(EnumByte), SameType, &[]), + (TrackedRewards, 28, Some("RewardName"), Some(1_337_472_711), Field(FName), SameType, &[]), + (TrackedRewards, 29, Some("LocalizedRewardName"), Some(483_770_233), Field(FTextTree), RawEntry, &[]), + (TrackedRewards, 30, Some("InstancesOfReward"), Some(2_922_243_316), Field(Int32), SameType, &[]), + (TrackedRewards, 31, Some("RewardGrantStrategy"), Some(3_589_631_714), Field(EnumByte), SameType, &[]), + (TrackedRewards, 32, Some("Source"), Some(1_118_571_008), Field(EnumByte), SameType, &[]), + (SelectedV2, 3, Some("EquippableDataAsset"), Some(1_793_937_854), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 4, Some("EquippableSkinDataAsset"), Some(3_765_038_216), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 5, Some("EquippableSkinLevelDataAsset"), Some(603_923_741), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 6, Some("EquippableSkinChromaDataAsset"), Some(3_166_589_204), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 7, Some("EquippableCharmDataAsset"), Some(3_345_806_642), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 8, Some("EquippableCharmLevelDataAsset"), Some(1_087_985_310), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 13, Some("EquippableAttachments"), Some(3_137_596_882), Nested, NoEntry, &[]), + (KillData, 3, Some("Victim"), Some(3_990_035_472), Field(ObjectNetGuid), NoneOrSame, &[]), + (KillData, 4, Some("KillingEquippableClass"), Some(2_071_131_011), Field(ObjectNetGuid), NoneOrSame, &[]), + (KillData, 5, Some("WeaponTheme"), Some(1_839_952_321), WeaponTheme, NoEntry, &[]), + (KillData, 6, Some("AssistingPlayers"), Some(1_689_463_717), Nested, NoEntry, &[]), + (KillData, 9, Some("DamageType"), Some(2_992_423_760), Field(ObjectNetGuid), NoneOrSame, &[]), + (KillData, 10, Some("DamageTaken"), Some(2_001_471_495), Field(Float), NoneOrSame, &[]), + // The observed byte code; no enum label is claimed. + (KillData, 11, Some("DamageRegion"), Some(3_229_265_809), Field(Byte), NoneOrSame, &[]), + (KillData, 12, Some("GameTimeElapsed"), Some(3_684_431_363), Field(Float), NoneOrSame, &[]), + (KillData, 13, Some("RoundTimestamp"), Some(2_328_473_242), Field(Float), NoneOrSame, &[]), + (KillData, 14, Some("RoundNumber"), Some(843_024_485), Field(Int32), NoneOrSame, &[]), + (KillData, 15, Some("bDidKillTriggerFinisher"), Some(2_795_684_046), Field(Bool), NoneOrSame, &[]), + (ServerActiveEffects, 5, None, None, Field(Bool), SameType, &[]), + (ServerActiveEffects, 6, None, None, Field(Bool), SameType, &[]), + (ServerActiveEffects, 7, None, None, Field(ObjectNetGuid), SameType, &[]), + (ServerActiveEffects, 8, None, None, Field(ObjectNetGuid), SameType, &[]), + (ServerActiveEffects, 9, Some("FloatValues"), Some(3_597_032_544), Effect(Floats), NoEntry, &[]), + (ServerActiveEffects, 17, Some("ObjectValues"), Some(865_691_585), Effect(Objects), NoEntry, &[]), + // No top-level overlay; the exact 192-bit windows decode on every measured build. + (ServerActiveEffects, 30, Some("Translation"), None, Field(VectorDouble), NoneOrSame, &[]), + (ServerActiveEffects, 31, Some("Scale3D"), None, Field(VectorDouble), NoneOrSame, &[]), + (ServerActiveEffects, 33, None, None, Field(Float), SameType, &[]), + (ServerActiveEffects, 34, None, None, Field(EnumByte), SameType, &[]), + (RequestedIgnoreActors, 5, Some("RequestedIgnoreActors"), Some(3_344_674_359), Field(ObjectNetGuid), NoneOrSame, &[]), + // All or nothing (`emit_flattened_array`), at the measured widths. + (ActiveBlinds, 3, Some("BlindId"), Some(2_836_858_544), Field(UInt32), NoneOrSame, &[32]), + // Signed: 3321413110 reproduces only as int64 (uint64 gives 2854897423), + // recomputed in tools/tests/test_compatible_checksum_facts.py. + (ActiveBlinds, 4, Some("EffectID"), Some(3_321_413_110), Field(Int64), NoneOrSame, &[64]), + (ActiveBlinds, 5, Some("SourceID"), Some(4_130_766_059), Field(FName), NoneOrSame, &[297]), + (ActiveBlinds, 6, Some("bLocalEffect"), Some(2_802_682_995), Field(Bool), NoneOrSame, &[1]), + (ActiveBlinds, 7, Some("bTransient"), Some(815_378_154), Field(Bool), NoneOrSame, &[1]), + (ActiveBlinds, 8, Some("InitialDuration"), Some(1_370_668_337), Field(Float), NoneOrSame, &[32]), + (ActiveBlinds, 9, Some("StartNetMovementTime"), Some(2_358_118_895), Field(Float), NoneOrSame, &[32]), + (ActiveBlinds, 10, Some("BlindConfig"), Some(4_121_438_116), Field(ObjectNetGuid), NoneOrSame, &[16]), + // A null actor is the one-byte IntPacked zero (59 windows in the 81-file audit). + (ActiveBlinds, 11, Some("CausingActor"), Some(2_370_661_694), Field(ObjectNetGuid), NoneOrSame, &[8, 16, 24]), + ] +}; + +/// The members of a [`Leaf::Nested`] container's array, apart from +/// [`LEAF_RULES`] so a top-level leaf at the same handle is never typed. +#[rustfmt::skip] +const NESTED_RULES: &[LeafRule] = { + use FieldType::ObjectNetGuid; + use Leaf::Field; + use MeasuredArrayRoute::{KillData, SelectedV2}; + use Overlay::NoneOrSame; + &[ + (SelectedV2, 14, Some("SocketAsset"), Some(3_666_994_016), Field(ObjectNetGuid), NoneOrSame, &[]), + (SelectedV2, 15, Some("AttachmentAsset"), Some(856_446_005), Field(ObjectNetGuid), NoneOrSame, &[]), + (KillData, 7, Some("AssistingPlayers"), Some(1_417_448_159), Field(ObjectNetGuid), NoneOrSame, &[]), + ] +}; + +/// The rule in `rules` for `route`'s `handle`, if the leaf meets every column. +fn leaf_rule( + rules: &[LeafRule], + route: MeasuredArrayRoute, + handle: u32, + width: u32, + name: Option<&str>, + checksum: Option, + resolved: Option, +) -> Option { + let &(_, _, want_name, want_checksum, leaf, overlay, widths) = rules + .iter() + .find(|rule| rule.0 == route && rule.1 == handle)?; + let wanted = match leaf { + Leaf::Field(field_type) => Some(field_type), + _ => None, + }; + let overlay_agrees = match overlay { + Overlay::NoEntry => resolved.is_none(), + Overlay::NoneOrSame => resolved.is_none() || resolved == wanted, + Overlay::SameType => resolved.is_some() && resolved == wanted, + Overlay::RawEntry => resolved == Some(FieldType::Raw), + }; + (overlay_agrees + && want_name.is_none_or(|want| name == Some(want)) + && want_checksum.is_none_or(|want| checksum == Some(want)) + && (widths.is_empty() || widths.contains(&width))) + .then_some(leaf) +} + +/// The handles `rules` types for `route`: all a strict preflight admits. +fn rule_handles(rules: &[LeafRule], route: MeasuredArrayRoute) -> SmallVec<[u32; 16]> { + rules + .iter() + .filter(|rule| rule.0 == route) + .map(|rule| rule.1) + .collect() +} + +pub(super) fn strict_nested_array_preflight(raw: &[u8], bit_count: u32, allowed: &[u32]) -> bool { + // The generic walker tolerates EOF terminators and skips zero-width members; + // these routes demand explicit terminators and check every handle before a + // zero-width member could vanish. + if bit_count == 0 { + return false; + } + let Ok(mut reader) = BitReader::with_bit_len(raw, u64::from(bit_count)) else { + return false; + }; + let Ok(capacity) = reader.read_int_packed() else { + return false; + }; + if capacity > vrf_decode::MAX_ELEMENTS { + return false; + } + let mut elements = 0; + loop { + if reader.at_end() { + return false; + } + let Ok(encoded_index) = reader.read_int_packed() else { + return false; + }; + if encoded_index == 0 { + return reader.at_end(); + } + if encoded_index > capacity || elements == vrf_decode::MAX_ELEMENTS { + return false; + } + elements += 1; + let mut fields = 0; + loop { + if reader.at_end() { + return false; + } + let Ok(encoded_handle) = reader.read_int_packed() else { + return false; + }; + if encoded_handle == 0 { + break; + } + if fields == vrf_decode::MAX_FIELDS_PER_ELEMENT { + return false; + } + fields += 1; + let handle = encoded_handle - 1; + if !allowed.contains(&handle) { + return false; + } + let Ok(payload_bits) = reader.read_int_packed() else { + return false; + }; + if payload_bits == 0 || reader.skip_bits(u64::from(payload_bits)).is_err() { + return false; + } + } + } +} + +/// The array inside a [`Leaf::Nested`] container, all or nothing: the strict +/// preflight, a clean exact walk, then every member matching [`NESTED_RULES`] +/// and decoding. A member refused after a clean walk counts once in `failures`. +fn decode_nested( + route: MeasuredArrayRoute, + container: &FlattenedField, + names: &[Option<&str>], + checksums: &[Option], + group_path: &str, + array: &mut ArrayDecodeStats, + failures: &mut u64, +) -> Option> { + let (raw, bit_count) = (&container.raw_bits, container.bit_count); + if !strict_nested_array_preflight(raw, bit_count, &rule_handles(NESTED_RULES, route)) { + array.errors += 1; + return None; + } + let mut walk = ArrayDecodeStats::default(); + let flattened = vrf_decode::decode_struct_array_exact(raw, bit_count, names, &mut walk); + array.merge_from(&walk); + if !walk.is_clean() { + return None; + } + let mut members = Vec::with_capacity(flattened.len()); + for leaf in flattened { + let name = declared_at(names, leaf.handle); + let resolved = vrf_decode::resolve_field_type(&TABLE, group_path, name, Some(leaf.handle)); + let checksum = declared_at(checksums, leaf.handle); + let rule = leaf_rule( + NESTED_RULES, + route, + leaf.handle, + leaf.bit_count, + name, + checksum, + resolved, + ); + let Some(Leaf::Field(field_type)) = rule else { + *failures += 1; + return None; + }; + // A failed decode is all-`None` and already counted. + let columns = decode_leaf_with_stats(field_type, &leaf.raw_bits, leaf.bit_count, failures); + if columns == (None, None, None, None) { + return None; + } + members.push((leaf, columns)); + } + Some(members) +} + +/// The array bits minus the one extra zero IntPacked an empty ActiveBlinds delta +/// may carry after its index terminator (60 windows in 41 of the 1,018 corpus +/// replays, all main pass). The parent keeps its bits and each spared byte is +/// counted (`ExportStats::active_blinds_empty_trailers`); every other shape +/// keeps the exact-window checks. +fn active_blind_array_bits(raw: &[u8], bit_count: u32) -> u32 { + let without_empty_trailer = (|| { + let mut reader = BitReader::with_bit_len(raw, u64::from(bit_count)).ok()?; + let capacity = reader.read_int_packed().ok()?; + if capacity > vrf_decode::MAX_ELEMENTS || reader.read_int_packed().ok()? != 0 { + return None; + } + if reader.bits_remaining() != 8 || reader.read_int_packed().ok()? != 0 { + return None; + } + Some(bit_count - 8) + })(); + without_empty_trailer.unwrap_or(bit_count) +} + +/// Decode a leaf a rule typed; a failure counts in `failures` and leaves the +/// columns null. +fn decode_leaf(leaf: Leaf, raw: &[u8], bit_count: u32, failures: &mut u64) -> DecodedColumns { + let text = match leaf { + Leaf::Field(field_type) => { + return decode_leaf_with_stats(field_type, raw, bit_count, failures); + } + Leaf::Nested => return (None, None, None, None), + Leaf::WeaponTheme => kill_weapon_theme(raw, bit_count), + Leaf::Effect(kind) => vrf_decode::decode_effect_blob_json(kind, raw, bit_count).ok(), + }; + if text.is_none() { + *failures = failures.saturating_add(1); + } + (None, None, None, text) +} + +/// A set bit, then an FString whose null terminator is required for every +/// nonzero length (the generic reader tolerates its absence) and checked, a +/// valid character in its slot included, before decoding; nothing may follow. +fn kill_weapon_theme(raw: &[u8], bit_count: u32) -> Option { + let mut reader = BitReader::with_bit_len(raw, u64::from(bit_count)).ok()?; + if !reader.read_bit().ok()? { + return None; + } + let mut framing = reader.clone(); + let length = framing.read_i32().ok()?; + let units = i64::from(length).unsigned_abs(); + let unit_bits = if length < 0 { 16 } else { 8 }; + if units * (unit_bits / 8) > 64 * 1024 { + return None; + } + if units != 0 { + framing.skip_bits((units - 1) * unit_bits).ok()?; + if framing.read_bits(unit_bits as u32).ok()? != 0 { + return None; + } + } + if framing.bits_remaining() != 0 { + return None; + } + let value = reader.read_fstring(64 * 1024).ok()?; + (reader.bits_remaining() == 0).then_some(value) +} + +/// The measured route a flattened-array parent belongs to, from its exact +/// group, name and checksum. Whether the replay's branch admits that route is +/// a separate question, answered by `ExportSink::admits`. +fn measured_array_route( + group: &str, + parent: &str, + checksum: Option, +) -> Option { + let route = match (group, parent, checksum) { + ( + "/Script/ShooterGame.OwnerExclusivePlayerInfo", + "AllPlayersObfuscatedPlayerInformation", + Some(1_349_268_968), + ) => MeasuredArrayRoute::AllPlayersObfuscatedPlayerInformation, + ("/Script/ShooterGame.OwnerExclusivePlayerInfo", "TrackedRewards", Some(976_048_801)) => { + MeasuredArrayRoute::TrackedRewards + } + ("/Script/ShooterGame.PersonalizationComponent", "SelectedV2", Some(4_218_721_055)) => { + MeasuredArrayRoute::SelectedV2 + } + ("/Script/ShooterGame.PlayerMatchStatsComponent", "KillData", Some(1_493_759_848)) => { + MeasuredArrayRoute::KillData + } + ( + "/Script/ShooterGame.EffectManagerComponent", + "ServerActiveEffects", + Some(3_301_618_856), + ) => MeasuredArrayRoute::ServerActiveEffects, + ( + "/Script/ShooterGame.FiniteSpeedMovementComponent", + "RequestedIgnoreActors", + Some(1_063_739_204), + ) => MeasuredArrayRoute::RequestedIgnoreActors, + ("/Script/ShooterGame.BlindManagerComponent", "ActiveBlinds", Some(3_853_965_310)) => { + MeasuredArrayRoute::ActiveBlinds + } + _ => return None, + }; + Some(route) +} + +/// A flattened array this module expands. +#[derive(Clone, Copy, PartialEq)] +enum ArrayKind { + /// `CombatReportComponent.Rounds`: the overlay, then [`decode_array_leaf`]. + Rounds, + /// `AbilityCastsThisRound`, whose `Effects` array the walker sees only + /// through [`ABILITY_CASTS_SCHEMA`]. + AbilityCasts, + /// A checksum-gated route this replay's branch admits. + Measured(MeasuredArrayRoute), +} + +/// The struct-blob fields that have a dedicated decoder in `vrf-decode`. +#[derive(Clone, Copy)] +enum StructBlob { + RoundResults, + TeamEconomy, + RoundInfos, +} + +/// One struct-blob element's `(member, value_i64, value_str)` rows. +type BlobMembers = Vec<(&'static str, Option, Option)>; + +impl ExportSink<'_> { + /// The replay's declared name and checksum for every handle of + /// `group_path` (empty for an unknown group), borrowed from `cache` alone so + /// `&mut self.stats` stays free. + fn declared_handles<'g>( + cache: &'g NetGuidCache, + group_path: &str, + ) -> (Vec>, Vec>) { + let Some(group) = cache.get_group_by_path(group_path) else { + return Default::default(); + }; + group + .fields + .iter() + .map(|slot| match slot { + Some(field) => (Some(field.name.as_str()), Some(field.compatible_checksum)), + None => (None, None), + }) + .unzip() + } + + /// Which array `parent` is on this group, if any: one classifier for the + /// schema, the walker and the leaf typing. + fn array_kind(&self, parent: &str, checksum: Option) -> Option { + let group = &*self.current_group_path; + match parent { + "Rounds" if group.contains("CombatReportComponent") => Some(ArrayKind::Rounds), + "AbilityCastsThisRound" if group.contains("Comp_AbilityStatisticsReplicator") => { + Some(ArrayKind::AbilityCasts) + } + _ => measured_array_route(group, parent, checksum) + .filter(|&route| self.admits(route)) + .map(ArrayKind::Measured), + } + } + + /// Flatten a known array field and emit one row per leaf, each nested row + /// after its raw container. + pub(super) fn emit_flattened_array( + &mut self, + field_name: Option<&str>, + checksum: Option, + raw: &[u8], + bit_count: u32, + ) { + let Some(parent) = field_name else { + return; + }; + let Some(kind) = self.array_kind(parent, checksum) else { + return; + }; + let (names, checksums) = Self::declared_handles(self.cache, &self.current_group_path); + let route = match kind { + ArrayKind::Measured(route) => Some(route), + ArrayKind::Rounds | ArrayKind::AbilityCasts => None, + }; + let flattened = match route { + None => { + let schema = if kind == ArrayKind::Rounds { + &COMBAT_ROUNDS_SCHEMA + } else { + &ABILITY_CASTS_SCHEMA + }; + vrf_decode::decode_struct_array( + raw, + bit_count, + Some(schema), + &names, + &mut self.stats.array, + ) + } + Some(route) => { + let blinds = route == MeasuredArrayRoute::ActiveBlinds; + let array_bits = if blinds { + active_blind_array_bits(raw, bit_count) + } else { + bit_count + }; + if array_bits != bit_count { + self.stats.active_blinds_empty_trailers += 1; + } + if blinds + && !strict_nested_array_preflight( + raw, + array_bits, + &rule_handles(LEAF_RULES, route), + ) + { + self.stats.array.errors += 1; + return; + } + // The sole measured empty variant: capacity one, the index-zero + // terminator and an opaque zero byte. A literal, not a relaxation. + if route == MeasuredArrayRoute::TrackedRewards + && bit_count == 24 + && raw == [2, 0, 0] + { + self.stats.tracked_rewards_opaque_empty_variants += 1; + return; + } + let mut walk = ArrayDecodeStats::default(); + let flattened = + vrf_decode::decode_struct_array_exact(raw, array_bits, &names, &mut walk); + self.stats.array.merge_from(&walk); + if !walk.is_clean() { + return; + } + flattened + } + }; + + // Every leaf is typed before any row is pushed, by the overlay keyed on + // the declared name: an ordinary field's resolution order minus the + // checksum-keyed steps (scoped types, checksum fallback), which type + // none of the untyped array leaves measured. + let group_path = &*self.current_group_path; + let (array, leaf_errors) = ( + &mut self.stats.array, + &mut self.stats.array_leaf_decode_errors, + ); + let leaves: Vec<_> = flattened + .iter() + .map(|f| { + let name = declared_at(&names, f.handle); + let resolved = + vrf_decode::resolve_field_type(&TABLE, group_path, name, Some(f.handle)); + let Some(route) = route else { + let typed = resolved.filter(|t| !matches!(t, FieldType::Raw | FieldType::Skip)); + return (typed.map(Leaf::Field), None); + }; + let checksum = declared_at(&checksums, f.handle); + let leaf = leaf_rule( + LEAF_RULES, + route, + f.handle, + f.bit_count, + name, + checksum, + resolved, + ); + let nested = if leaf == Some(Leaf::Nested) { + decode_nested(route, f, &names, &checksums, group_path, array, leaf_errors) + } else { + None + }; + (leaf, nested) + }) + .collect(); + if route == Some(MeasuredArrayRoute::ActiveBlinds) + && leaves.iter().any(|(leaf, _)| leaf.is_none()) + { + self.stats.array_leaf_decode_errors += 1; + return; + } + + let rows_before = self.stats.fields_emitted; + for (f, (leaf, nested)) in flattened.iter().zip(leaves) { + let errors = &mut self.stats.array_leaf_decode_errors; + let columns = match leaf { + Some(leaf) => decode_leaf(leaf, &f.raw_bits, f.bit_count, errors), + // CombatReport-only: handle 3 is Int32 there and an FString in + // AbilityCastsThisRound. + None if kind == ArrayKind::Rounds => { + decode_array_leaf(f.handle, &f.raw_bits, f.bit_count, errors) + } + None => (None, None, None, None), + }; + // `f.path` carries its own leading separator: "Rounds[0].RoundNumber". + self.push_child( + f.handle, + &[parent, &f.path], + f.bit_count, + &f.raw_bits, + columns, + ); + for (member, columns) in nested.into_iter().flatten() { + let (handle, bits) = (member.handle, member.bit_count); + let name: [&str; 3] = [parent, &f.path, &member.path]; + self.push_child(handle, &name, bits, &member.raw_bits, columns); + } + } + if let Some(route) = route { + *self.stats.route_children(route) += self.stats.fields_emitted - rows_before; + } + } + + /// Push one row decoded out of a parent payload, named by concatenating + /// `name`. Addressed inside the payload, not by a declared handle, so its + /// checksum is null. + pub(super) fn push_child( + &mut self, + handle: u32, + name: &[&str], + bit_count: u32, + raw: &[u8], + (value_i64, value_f64, value_bool, value_str): DecodedColumns, + ) { + let field_name = self + .channel_state + .names + .intern_fmt(|out| name.iter().for_each(|part| out.push_str(part))); + self.push_field(FieldValues { + handle, + field_name: Some(field_name), + compatible_checksum: None, + bit_count, + raw_bits: Some(SmallVec::from_slice(raw)), + value_i64, + value_f64, + value_bool, + value_str, + }); + } + + /// Which dedicated decoder owns this field on this group, if any. The + /// group goes through `vrf_decode::canonical_group`, the overlay's own + /// alias table: Swiftplay carries `RoundResults` and `TeamEconomy` on + /// `Swiftplay_EoRCredits_GameState_C`, which a bare `BombGameState` test + /// misses. + fn struct_blob_kind(&self, field_name: Option<&str>) -> Option { + let game_state = + || vrf_decode::canonical_group(&self.current_group_path).contains("BombGameState"); + match field_name? { + "RoundResults" if game_state() => Some(StructBlob::RoundResults), + "TeamEconomy" if game_state() => Some(StructBlob::TeamEconomy), + "RoundInfos" if self.current_group_path.contains("OwnerExclusivePlayerInfo") => { + Some(StructBlob::RoundInfos) + } + _ => None, + } + } + + /// Decode a `MultiItemSlot.MultiContents` blob (`TArray`) into + /// one `MultiContents[index]` row per item, the NetGUID in `value_i64` as + /// for `ItemSlot.Contents`; the parent stays `Raw`. The wire element index + /// labels the row: arrays are delta-replicated per element, so a re-send may + /// carry only the changed slot, which arrival order would put in slot 0. + pub(super) fn emit_multi_contents( + &mut self, + field_name: Option<&str>, + raw: &[u8], + bit_count: u32, + ) { + if field_name != Some("MultiContents") || !self.current_group_path.contains("MultiItemSlot") + { + return; + } + let guids = + vrf_decode::decode_object_ref_array_with_stats(raw, bit_count, &mut self.stats.array); + for (index, guid) in &guids { + self.emit_struct_sub_field( + |out| put(out, format_args!("MultiContents[{index}]")), + Some(i64::from(*guid)), + None, + ); + self.stats.multi_contents_items_emitted += 1; + } + } + + /// Decode a struct blob with its dedicated decoder and emit one + /// `{field}[{index}].{member}` row per member that has a value. + pub(super) fn decode_struct_blob( + &mut self, + field_name: Option<&str>, + raw: &[u8], + bit_count: u32, + ) { + let (Some(name), Some(kind)) = (field_name, self.struct_blob_kind(field_name)) else { + return; + }; + let elements: Option> = match kind { + StructBlob::RoundResults => { + let decoded = self.decode_blob(raw, bit_count, structs::decode_round_results); + decoded.map(|rows| { + rows.into_iter() + .map(|rr| { + let role = rr.winning_team_role.map(|r| r.as_str().to_owned()); + let outcome = rr.round_result.map(|o| o.as_str().to_owned()); + let members = vec![ + ("RoundNumber", Some(i64::from(rr.round_number)), None), + ("WinningTeam", None, rr.winning_team), + ("WinningTeamRole", None, role), + ("RoundResult", None, outcome), + ]; + (rr.round_number, members) + }) + .collect() + }) + } + StructBlob::TeamEconomy => { + let decoded = + self.decode_blob(raw, bit_count, structs::decode_team_economy_declared); + decoded.map(|rows| { + rows.into_iter() + .map(|te| { + let members = vec![ + ("Index", Some(i64::from(te.index)), None), + ("ReplicationId", te.replication_id.map(i64::from), None), + ("LoadoutValue", te.loadout_value.map(i64::from), None), + ( + "AverageLoadoutValue", + te.average_loadout_value.map(i64::from), + None, + ), + ]; + (te.index, members) + }) + .collect() + }) + } + StructBlob::RoundInfos => { + let decoded = self.decode_blob(raw, bit_count, structs::decode_round_infos); + decoded.map(|rows| { + rows.into_iter() + .map(|ri| { + let members = [ + ("RoundNumber", ri.round_number), + ("StartOfRoundMoney", ri.start_of_round_money), + ("StartOfRoundLoadoutValue", ri.start_of_round_loadout_value), + ("EndOfRoundMoney", ri.end_of_round_money), + ("EndOfRoundLoadoutValue", ri.end_of_round_loadout_value), + ]; + let members = + members.map(|(member, v)| (member, v.map(i64::from), None)); + (ri.index, Vec::from(members)) + }) + .collect() + }) + } + }; + let Some(elements) = elements else { + return; + }; + if !elements.is_empty() { + self.stats.struct_blobs_decoded += 1; + } + for (index, members) in elements { + for (member, value_i64, value_str) in members { + if value_i64.is_some() || value_str.is_some() { + self.emit_struct_sub_field( + |out| put(out, format_args!("{name}[{index}].{member}")), + value_i64, + value_str, + ); + } + } + } + } + + /// Count a struct-blob failure rather than drop it: it costs no rows (the + /// decoders are additive), but it must not go unsaid. + fn record_blob_failure(&mut self, err: &dyn std::fmt::Display) { + self.stats.struct_blobs_failed += 1; + if self.stats.struct_blob_first_error.is_none() { + self.stats.struct_blob_first_error = Some(err.to_string()); + } + } + + /// Run one struct-blob decoder over the blob's declared bit length and + /// the group's declared names. Every failure, the reader's included, is + /// recorded here, so the wording cannot drift between the decoders. + fn decode_blob( + &mut self, + raw: &[u8], + bit_count: u32, + decode: impl FnOnce(&mut BitReader<'_>, &[Option<&str>]) -> structs::Result>, + ) -> Option> { + let Ok(mut reader) = BitReader::with_bit_len(raw, u64::from(bit_count)) else { + self.record_blob_failure(&"declared bit length exceeds buffer"); + return None; + }; + // The decoded elements own their strings, so once `decode` returns + // nothing borrows `self.cache` and a failure can be recorded. + let (names, _) = Self::declared_handles(self.cache, &self.current_group_path); + match decode(&mut reader, &names) { + Ok(results) => Some(results), + Err(err) => { + self.record_blob_failure(&err); + None + } + } + } + + /// Emit one struct-blob member row, its name built by `name` straight into + /// the interner's scratch buffer (no allocation). Only i64 and str are + /// parameters: no member decodes to a float or a bool. + fn emit_struct_sub_field( + &mut self, + name: impl FnOnce(&mut String), + value_i64: Option, + value_str: Option, + ) { + let field_name = self.channel_state.names.intern_fmt(name); + self.push_field(FieldValues { + handle: 0, + field_name: Some(field_name), + bit_count: 0, + raw_bits: None, + value_i64, + value_str, + ..FieldValues::default() + }); + } +} + +/// Decode one array leaf with a type the caller already resolved: the one +/// decode-and-widen every typing path shares. +pub(super) fn decode_leaf_with_stats( + field_type: FieldType, + raw: &[u8], + bit_count: u32, + failures: &mut u64, +) -> DecodedColumns { + vrf_decode::decode_field(field_type, raw, bit_count).map_or_else( + |_| { + *failures = failures.saturating_add(1); + DecodedColumns::default() + }, + vrf_decode::DecodedValue::into_columns, + ) +} + +/// A handle -> type map derived from the CombatRoundReports descriptors: the +/// floor for leaves the overlay cannot name (the caller asks it first). All +/// `None` for an unknown handle or a failed decode. +fn decode_array_leaf( + handle: u32, + raw: &[u8], + bit_count: u32, + failures: &mut u64, +) -> DecodedColumns { + let field_type = match handle { + 3 | 5 | 19 | 21 | 46 | 81 | 96 => FieldType::Int32, + 18 | 20 | 47 | 82 => FieldType::Float, + 22 | 25 | 48 | 49 | 83 | 84 | 103 => FieldType::Bool, + 23 | 45 | 80 => FieldType::EnumByte, + 13 | 24 | 50 | 85 | 98 => FieldType::ObjectNetGuid, + 11 => FieldType::FString, + 12 => FieldType::FName, + _ => return (None, None, None, None), + }; + + decode_leaf_with_stats(field_type, raw, bit_count, failures) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::sink::test_fixtures::Rig; + use crate::vendor::sink::{ExportStats, MeasuredArrayRoutes, RecordBuffers}; + use std::sync::Arc; + use vrf_export::FieldRecord; + use vrf_net::field::FieldSink; + use vrf_testkit::{BitWrite, pack, unpack}; + + const OWNER: &str = "/Script/ShooterGame.OwnerExclusivePlayerInfo"; + const OWNER_PARENT: &str = "AllPlayersObfuscatedPlayerInformation"; + const OWNER_CHECKSUM: u32 = 1_349_268_968; + const REWARDS_PARENT: &str = "TrackedRewards"; + const REWARDS_CHECKSUM: u32 = 976_048_801; + const SELECTED_GROUP: &str = "/Script/ShooterGame.PersonalizationComponent"; + const SELECTED_PARENT: &str = "SelectedV2"; + const SELECTED_CHECKSUM: u32 = 4_218_721_055; + const KILL_GROUP: &str = "/Script/ShooterGame.PlayerMatchStatsComponent"; + const KILL_PARENT: &str = "KillData"; + const KILL_CHECKSUM: u32 = 1_493_759_848; + const MEASURED_BUILD: &str = "++Ares-Core+release-13.05"; + const EFFECTS_GROUP: &str = "/Script/ShooterGame.EffectManagerComponent"; + const IGNORE_GROUP: &str = "/Script/ShooterGame.FiniteSpeedMovementComponent"; + const BLINDS: (&str, &str, u32) = ( + "/Script/ShooterGame.BlindManagerComponent", + "ActiveBlinds", + 3_853_965_310, + ); + /// The nine measured ActiveBlinds member declarations. + const BLIND_MEMBERS: [(u32, &str, u32); 9] = [ + (3, "BlindId", 2_836_858_544), + (4, "EffectID", 3_321_413_110), + (5, "SourceID", 4_130_766_059), + (6, "bLocalEffect", 2_802_682_995), + (7, "bTransient", 815_378_154), + (8, "InitialDuration", 1_370_668_337), + (9, "StartNetMovementTime", 2_358_118_895), + (10, "BlindConfig", 4_121_438_116), + (11, "CausingActor", 2_370_661_694), + ]; + + /// A row's four typed columns. + fn values(row: &FieldRecord) -> (Option, Option, Option, Option<&str>) { + ( + row.value_i64, + row.value_f64, + row.value_bool, + row.value_str.as_deref(), + ) + } + + /// `leaf_rule` for a top-level leaf whose width no rule checks. + fn typed( + route: MeasuredArrayRoute, + handle: u32, + name: &str, + checksum: u32, + resolved: Option, + ) -> Option { + leaf_rule( + LEAF_RULES, + route, + handle, + 0, + Some(name), + Some(checksum), + resolved, + ) + } + + fn one_leaf(handle: u32, payload: &[bool]) -> Vec { + let mut bits = Vec::new(); + for value in [1, 1, handle + 1, payload.len() as u32] { + bits.int_packed(value); + } + bits.extend_from_slice(payload); + bits.int_packed(0); + bits.int_packed(0); + bits + } + + fn one_element(fields: &[(u32, Vec)]) -> Vec { + let mut bits = Vec::new(); + bits.int_packed(1); + bits.int_packed(1); + for (handle, payload) in fields { + bits.int_packed(handle + 1); + bits.int_packed(payload.len() as u32); + bits.extend_from_slice(payload); + } + bits.int_packed(0); + bits.int_packed(0); + bits + } + + /// One ActiveBlinds element with every member at its measured width. + fn blind_element(effect_id: i64) -> Vec { + let mut source_id = vec![false]; + source_id.extend(unpack(&(29i32).to_le_bytes())); + source_id.extend(unpack(b"DedicatedServerWorldSourceID\0")); + source_id.extend(unpack(&0i32.to_le_bytes())); + assert_eq!(source_id.len(), 297); + let mut blind_config = Vec::new(); + blind_config.int_packed(256); + let mut causing_actor = Vec::new(); + causing_actor.int_packed(257); + one_element(&[ + (3, unpack(&7u32.to_le_bytes())), + (4, unpack(&effect_id.to_le_bytes())), + (5, source_id), + (6, vec![true]), + (7, vec![false]), + (8, unpack(&1.5f32.to_le_bytes())), + (9, unpack(&10.0f32.to_le_bytes())), + (10, blind_config), + (11, causing_actor), + ]) + } + + fn kill_weapon_theme_payload(value: &str, utf16: bool) -> Vec { + let mut bits = vec![true]; + let (length, body) = if value.is_empty() && !utf16 { + (0, Vec::new()) + } else if utf16 { + let units: Vec = value.encode_utf16().chain([0]).collect(); + let body = units.iter().flat_map(|v| v.to_le_bytes()).collect(); + (-(units.len() as i32), body) + } else { + let mut body = value.as_bytes().to_vec(); + body.push(0); + (body.len() as i32, body) + }; + bits.extend(unpack(&length.to_le_bytes())); + bits.extend(unpack(&body)); + bits + } + + fn export_array( + identity: (&str, &str, u32), + leaves: &[(u32, &str, u32)], + bits: &[bool], + branch: Option<&str>, + ) -> (RecordBuffers, ExportStats) { + let (group, parent, checksum) = identity; + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new(group.into(), 7, 128)) + .unwrap(); + for (handle, name, compatible_checksum) in + std::iter::once((0, parent, checksum)).chain(leaves.iter().copied()) + { + assert!(rig.cache.set_field_on_group( + 7, + vrf_schema::NetFieldExport { + handle, + compatible_checksum, + name: name.into(), + } + )); + } + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from(group)); + if let Some(branch) = branch { + sink.enable_measured_array_routes(branch); + } + let raw = pack(bits); + sink.on_field( + 0, + bits.len() as u32, + BitReader::with_bit_len(&raw, bits.len() as u64).unwrap(), + ); + let stats = sink.stats; + (rig.records, stats) + } + + #[test] + fn measured_array_emits_typed_child_before_exact_raw_parent() { + let bits = one_leaf(49, &[true]); + let (records, stats) = export_array( + (OWNER, OWNER_PARENT, OWNER_CHECKSUM), + &[(49, "bIsAfk", 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + let child = &records.fields[0]; + assert_eq!( + child.field_name.as_deref(), + Some("AllPlayersObfuscatedPlayerInformation[0].bIsAfk") + ); + assert_eq!(child.value_bool, Some(true)); + assert_eq!(child.raw_bits.as_deref(), Some([1u8].as_slice())); + assert_eq!(child.bit_count, 1); + assert_eq!(child.compatible_checksum, None); + let parent = &records.fields[1]; + assert_eq!(parent.field_name.as_deref(), Some(OWNER_PARENT)); + assert_eq!(parent.raw_bits.as_deref(), Some(pack(&bits).as_slice())); + assert_eq!(parent.bit_count, bits.len() as u32); + assert_eq!(stats.array.fields_emitted, 1); + assert_eq!(stats.fields_emitted, 2); + } + + #[test] + fn tracked_rewards_types_only_the_four_verified_leaf_identities() { + let fname_zero = vec![true, false, false, false, false, false, false, false, false]; + for (handle, name, checksum, payload, want_i64, want_str) in [ + (28, "RewardName", 1_337_472_711, fname_zero, None, Some("0")), + ( + 30, + "InstancesOfReward", + 2_922_243_316, + vec![false; 32], + Some(0), + None, + ), + ( + 31, + "RewardGrantStrategy", + 3_589_631_714, + vec![false; 2], + Some(0), + None, + ), + ( + 32, + "Source", + 1_118_571_008, + vec![true, true, false], + Some(3), + None, + ), + ] { + let bits = one_leaf(handle, &payload); + let (records, _) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(handle, name, checksum)], + &bits, + Some(MEASURED_BUILD), + ); + let child = &records.fields[0]; + assert_eq!(child.value_i64, want_i64, "{name}"); + assert_eq!(child.value_str.as_deref(), want_str, "{name}"); + assert_eq!(child.raw_bits.as_deref(), Some(pack(&payload).as_slice())); + } + } + + #[test] + fn tracked_rewards_refuses_wrong_child_identity_or_resolved_type() { + let bits = one_leaf(30, &[false; 32]); + for (name, checksum) in [("OtherName", 2_922_243_316), ("InstancesOfReward", 0)] { + let (records, _) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(30, name, checksum)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, None, "{name}/{checksum}"); + } + assert_eq!( + typed( + MeasuredArrayRoute::TrackedRewards, + 30, + "InstancesOfReward", + 2_922_243_316, + Some(FieldType::Float), + ), + None + ); + } + + #[test] + fn tracked_rewards_localized_text_requires_its_raw_declaration() { + let rewards = MeasuredArrayRoute::TrackedRewards; + assert_eq!( + typed( + rewards, + 29, + "LocalizedRewardName", + 483_770_233, + Some(FieldType::Raw) + ), + Some(Leaf::Field(FieldType::FTextTree)) + ); + for (handle, name, checksum, resolved) in [ + (29, "Other", 483_770_233, FieldType::Raw), + (29, "LocalizedRewardName", 0, FieldType::Raw), + (29, "LocalizedRewardName", 483_770_233, FieldType::Skip), + (29, "LocalizedRewardName", 483_770_233, FieldType::FText), + (28, "LocalizedRewardName", 483_770_233, FieldType::Raw), + ] { + assert_eq!(typed(rewards, handle, name, checksum, Some(resolved)), None); + } + } + + #[test] + fn tracked_rewards_bad_typed_width_keeps_raw_leaf_and_counts_error() { + let bits = one_leaf(30, &[false; 8]); + let (records, stats) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(30, "InstancesOfReward", 2_922_243_316)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + assert_eq!(records.fields[0].raw_bits.as_deref(), Some([0].as_slice())); + assert_eq!(records.fields[0].value_i64, None); + assert_eq!(stats.array_leaf_decode_errors, 1); + } + + #[test] + fn localized_reward_text_keeps_raw_on_success_and_failure() { + let empty = unpack(&[0, 0, 0, 0, 255, 0, 0, 0, 0]); + for (payload, errors, expected) in [ + ( + empty.clone(), + 0, + Some(r#"{"flags":0,"history":255,"kind":"empty"}"#), + ), + (empty[..empty.len() - 1].to_vec(), 1, None), + ] { + let bits = one_leaf(29, &payload); + let (records, stats) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(29, "LocalizedRewardName", 483_770_233)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + assert_eq!(values(&records.fields[0]), (None, None, None, expected)); + assert_eq!(stats.array_leaf_decode_errors, errors); + assert_eq!( + records.fields[1].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + } + } + + #[test] + fn selected_v2_types_only_the_six_qualified_object_net_guid_leaves() { + let mut multi_byte = Vec::new(); + multi_byte.int_packed(128); + let (records, _) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[(3, "EquippableDataAsset", 1_793_937_854)], + &one_leaf(3, &multi_byte), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, Some(128)); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some([1, 2].as_slice()) + ); + + let zero = vec![false; 8]; + let (records, _) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[(8, "EquippableCharmLevelDataAsset", 1_087_985_310)], + &one_leaf(8, &zero), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, Some(0)); + + for (name, checksum) in [("Other", 1_793_937_854), ("EquippableDataAsset", 0)] { + let (records, _) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[(3, name, checksum)], + &one_leaf(3, &multi_byte), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, None, "{name}/{checksum}"); + } + for (handle, name, checksum) in [ + (9, "A", 3_055_317_389), + (13, "EquippableAttachments", 3_137_596_882), + (14, "SocketAsset", 3_666_994_016), + (15, "AttachmentAsset", 856_446_005), + ] { + let (records, _) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[(handle, name, checksum)], + &one_leaf(handle, &multi_byte), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, None, "{name} remains raw"); + } + // A contradictory overlay, or an explicit Raw/Skip (not an absent + // overlay), leaves the leaf raw. + for blocked in [FieldType::Float, FieldType::Raw, FieldType::Skip] { + assert_eq!( + typed( + MeasuredArrayRoute::SelectedV2, + 3, + "EquippableDataAsset", + 1_793_937_854, + Some(blocked), + ), + None, + "{blocked:?}" + ); + } + } + + #[test] + fn selected_v2_bad_object_net_guid_windows_stay_raw_and_count_errors() { + for payload in [unpack(&[1]), unpack(&[1, 1, 1, 1, 0x20])] { + let (records, stats) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[(3, "EquippableDataAsset", 1_793_937_854)], + &one_leaf(3, &payload), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_i64, None); + assert_eq!(stats.array_leaf_decode_errors, 1); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + } + } + + #[test] + fn kill_data_types_only_qualified_primitive_leaves() { + let mut object = Vec::new(); + object.int_packed(128); + let float = unpack(&(-1.5f32).to_le_bytes()); + let int = unpack(&(-2i32).to_le_bytes()); + for (handle, name, checksum, payload, vi, vf, vb) in [ + ( + 3, + "Victim", + 3_990_035_472, + object.clone(), + Some(128), + None, + None, + ), + ( + 4, + "KillingEquippableClass", + 2_071_131_011, + vec![false; 8], + Some(0), + None, + None, + ), + ( + 9, + "DamageType", + 2_992_423_760, + object.clone(), + Some(128), + None, + None, + ), + ( + 10, + "DamageTaken", + 2_001_471_495, + float.clone(), + None, + Some(-1.5), + None, + ), + ( + 11, + "DamageRegion", + 3_229_265_809, + vec![true, false, true], + Some(5), + None, + None, + ), + ( + 12, + "GameTimeElapsed", + 3_684_431_363, + float.clone(), + None, + Some(-1.5), + None, + ), + ( + 13, + "RoundTimestamp", + 2_328_473_242, + float, + None, + Some(-1.5), + None, + ), + (14, "RoundNumber", 843_024_485, int, Some(-2), None, None), + ( + 15, + "bDidKillTriggerFinisher", + 2_795_684_046, + vec![false], + None, + None, + Some(false), + ), + ] { + let (records, _) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[(handle, name, checksum)], + &one_leaf(handle, &payload), + Some(MEASURED_BUILD), + ); + let child = &records.fields[0]; + assert_eq!(child.value_i64, vi, "{name}"); + assert_eq!(child.value_f64, vf, "{name}"); + assert_eq!(child.value_bool, vb, "{name}"); + assert_eq!(child.raw_bits.as_deref(), Some(pack(&payload).as_slice())); + } + } + + #[test] + fn kill_data_weapon_theme_decodes_exact_prefixed_fstrings() { + for (value, utf16) in [ + ("/Game/Themes/Standard", false), + ("\u{d14c}\u{b9c8}", true), + ("", false), + ] { + let payload = kill_weapon_theme_payload(value, utf16); + let (records, stats) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[(5, "WeaponTheme", 1_839_952_321)], + &one_leaf(5, &payload), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_str.as_deref(), Some(value)); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + assert_eq!(stats.array_leaf_decode_errors, 0); + } + } + + #[test] + fn kill_data_weapon_theme_rejects_bad_flag_terminator_and_residual() { + let mut bad_flag = kill_weapon_theme_payload("x", false); + bad_flag[0] = false; + let mut bad_terminator = kill_weapon_theme_payload("x", false); + // Use a valid UTF-8 byte so rejection cannot come from UTF decoding. + let last = bad_terminator.len() - 8; + bad_terminator[last] = true; + let mut bad_wide_terminator = kill_weapon_theme_payload("x", true); + let last_wide = bad_wide_terminator.len() - 16; + bad_wide_terminator[last_wide] = true; + let mut residual = kill_weapon_theme_payload("x", false); + residual.push(false); + let mut invalid_utf8 = vec![true]; + invalid_utf8.extend(unpack(&2i32.to_le_bytes())); + invalid_utf8.extend(unpack(&[0xff, 0])); + for payload in [ + bad_flag, + bad_terminator, + bad_wide_terminator, + residual, + invalid_utf8, + ] { + let (records, stats) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[(5, "WeaponTheme", 1_839_952_321)], + &one_leaf(5, &payload), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_str, None); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + assert_eq!(stats.array_leaf_decode_errors, 1); + } + } + + #[test] + fn kill_data_refuses_wrong_child_identity_and_any_overlay_disagreement() { + let kill = MeasuredArrayRoute::KillData; + assert!(typed(kill, 10, "DamageTaken", 2_001_471_495, None).is_some()); + for (name, checksum) in [("Other", 2_001_471_495), ("DamageTaken", 0)] { + assert!(typed(kill, 10, name, checksum, None).is_none()); + let payload = unpack(&1.5f32.to_le_bytes()); + let (records, _) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[(10, name, checksum)], + &one_leaf(10, &payload), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_f64, None); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + } + let payload = kill_weapon_theme_payload("theme", false); + let (records, _) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[(5, "Other", 1_839_952_321)], + &one_leaf(5, &payload), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields[0].value_str, None); + for blocked in [FieldType::Int32, FieldType::Raw, FieldType::Skip] { + assert!(typed(kill, 10, "DamageTaken", 2_001_471_495, Some(blocked)).is_none()); + } + let theme = Some(FieldType::FString); + assert!(typed(kill, 5, "WeaponTheme", 1_839_952_321, theme).is_none()); + } + + #[test] + fn measured_nested_arrays_emit_after_their_preserved_raw_containers() { + let mut first = Vec::new(); + first.int_packed(128); + let mut second = Vec::new(); + second.int_packed(9); + let selected_nested = one_element(&[(14, first.clone()), (15, second.clone())]); + let (records, stats) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[ + (13, "EquippableAttachments", 3_137_596_882), + (14, "SocketAsset", 3_666_994_016), + (15, "AttachmentAsset", 856_446_005), + ], + &one_leaf(13, &selected_nested), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 4); + assert_eq!( + records.fields[0].field_name.as_deref(), + Some("SelectedV2[0].EquippableAttachments") + ); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&selected_nested).as_slice()) + ); + assert_eq!( + records.fields[1].field_name.as_deref(), + Some("SelectedV2[0].EquippableAttachments[0].SocketAsset") + ); + assert_eq!(records.fields[1].value_i64, Some(128)); + assert_eq!( + records.fields[1].raw_bits.as_deref(), + Some(pack(&first).as_slice()) + ); + assert_eq!( + records.fields[2].field_name.as_deref(), + Some("SelectedV2[0].EquippableAttachments[0].AttachmentAsset") + ); + assert_eq!(records.fields[2].value_i64, Some(9)); + assert_eq!( + records.fields[3].field_name.as_deref(), + Some(SELECTED_PARENT) + ); + assert_eq!(stats.array.fields_emitted, 3); + + let kill_nested = one_element(&[(7, first.clone())]); + let (records, stats) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[ + (6, "AssistingPlayers", 1_689_463_717), + (7, "AssistingPlayers", 1_417_448_159), + ], + &one_leaf(6, &kill_nested), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 3); + assert_eq!( + records.fields[0].field_name.as_deref(), + Some("KillData[0].AssistingPlayers") + ); + assert_eq!( + records.fields[1].field_name.as_deref(), + Some("KillData[0].AssistingPlayers[0].AssistingPlayers") + ); + assert_eq!(records.fields[1].value_i64, Some(128)); + assert_eq!(records.fields[2].field_name.as_deref(), Some(KILL_PARENT)); + assert_eq!(stats.array.fields_emitted, 2); + } + + #[test] + fn nested_array_preflight_requires_bounds_nonzero_windows_and_terminators() { + let valid = one_element(&[(7, unpack(&[2]))]); + assert!(strict_nested_array_preflight( + &pack(&valid), + valid.len() as u32, + &[7] + )); + assert!(!strict_nested_array_preflight(&[], 0, &[7])); + assert!(!strict_nested_array_preflight( + &pack(&valid[..valid.len() - 8]), + (valid.len() - 8) as u32, + &[7] + )); + let mut suffix = valid.clone(); + suffix.extend([false; 8]); + assert!(!strict_nested_array_preflight( + &pack(&suffix), + suffix.len() as u32, + &[7] + )); + let zero_width = one_element(&[(7, Vec::new())]); + assert!(!strict_nested_array_preflight( + &pack(&zero_width), + zero_width.len() as u32, + &[7] + )); + let unexpected = one_element(&[(8, unpack(&[2]))]); + assert!(!strict_nested_array_preflight( + &pack(&unexpected), + unexpected.len() as u32, + &[7] + )); + // The generic array walker skips zero-width members. The projectile + // route must reject one even when all three expected members follow. + let path_with_unknown_zero = one_element(&[ + (4, Vec::new()), + (1, unpack(&[0; 4])), + (2, unpack(&[0; 24])), + (3, unpack(&[0; 24])), + ]); + assert!(!strict_nested_array_preflight( + &pack(&path_with_unknown_zero), + path_with_unknown_zero.len() as u32, + &[1, 2, 3] + )); + let mut capacity_limit = Vec::new(); + capacity_limit.int_packed(vrf_decode::MAX_ELEMENTS + 1); + capacity_limit.int_packed(0); + assert!(!strict_nested_array_preflight( + &pack(&capacity_limit), + capacity_limit.len() as u32, + &[7] + )); + let mut index_range = Vec::new(); + index_range.int_packed(1); + index_range.int_packed(2); + assert!(!strict_nested_array_preflight( + &pack(&index_range), + index_range.len() as u32, + &[7] + )); + let fields = (0..=vrf_decode::MAX_FIELDS_PER_ELEMENT) + .map(|_| (7, unpack(&[0]))) + .collect::>(); + let field_limit = one_element(&fields); + assert!(!strict_nested_array_preflight( + &pack(&field_limit), + field_limit.len() as u32, + &[7] + )); + } + + #[test] + fn active_blinds_empty_delta_with_zero_trailer_is_complete() { + let identity = BLINDS; + // Captured 57 times across 13.01/13.02/13.04/13.05: capacity + // one (56 cases) or two (one case), no changed elements, zero trailer. + for capacity in [1, 2] { + let mut bits = Vec::new(); + bits.int_packed(capacity); + bits.int_packed(0); + let (_, control) = export_array(identity, &[], &bits, Some(MEASURED_BUILD)); + assert_eq!(control.array.errors, 0); + assert_eq!( + control.active_blinds_empty_trailers, 0, + "no trailer to spare" + ); + bits.int_packed(0); + let (records, stats) = export_array(identity, &[], &bits, Some(MEASURED_BUILD)); + assert_eq!(stats.array.errors, 0, "capacity {capacity}"); + assert_eq!( + stats.active_blinds_empty_trailers, 1, + "the tolerance must be seen firing" + ); + assert_eq!(stats.array.unconsumed_root_bits, 0); + assert_eq!(stats.array_leaf_decode_errors, 0); + assert_eq!( + records.fields.len(), + 1, + "unchanged elements add no children" + ); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + } + } + + #[test] + fn active_blinds_null_causing_actor_is_a_decoded_reference() { + let identity = BLINDS; + // The 59 rejected value windows contain a one-byte IntPacked zero. + // Keep the positive reference as a control through the same sink. + for reference in [257, 0] { + let mut payload = Vec::new(); + payload.int_packed(reference); + let bits = one_leaf(11, &payload); + let (records, stats) = export_array( + identity, + &[(11, "CausingActor", 2_370_661_694)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(stats.array.errors, 0); + assert_eq!(stats.array_leaf_decode_errors, 0, "reference {reference}"); + assert_eq!(records.fields.len(), 2); + assert_eq!(records.fields[0].value_i64, Some(i64::from(reference))); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&payload).as_slice()) + ); + assert_eq!( + records.fields[1].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + } + } + + #[test] + fn active_blinds_invalid_trailers_and_references_still_fail() { + let identity = BLINDS; + // An empty delta with a two-byte zero trailer; the nonzero one-byte + // trailers are `active_blinds_empty_delta_rejects_every_nonzero_trailer_byte`. + let bits = unpack(&[2, 0, 0, 0]); + let (records, stats) = export_array(identity, &[], &bits, Some(MEASURED_BUILD)); + assert_eq!(stats.array.errors, 1); + assert_eq!(stats.active_blinds_empty_trailers, 0, "refused, not spared"); + assert_eq!(records.fields.len(), 1); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + for payload in [unpack(&[1]), unpack(&[0, 0]), vec![false; 7]] { + let bits = one_leaf(11, &payload); + let (records, stats) = export_array( + identity, + &[(11, "CausingActor", 2_370_661_694)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(stats.array_leaf_decode_errors, 1); + assert!(records.fields.iter().all(|row| row.value_i64.is_none())); + assert_eq!( + records.fields.last().unwrap().raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + } + let mut populated = one_leaf(11, &unpack(&[0])); + populated.int_packed(0); + let (_, stats) = export_array( + identity, + &[(11, "CausingActor", 2_370_661_694)], + &populated, + Some(MEASURED_BUILD), + ); + assert_eq!( + stats.array.errors, 1, + "only empty deltas admit the zero trailer" + ); + assert_eq!(stats.active_blinds_empty_trailers, 0); + assert!( + !strict_nested_array_preflight(&[2, 0, 0], 24, &[11]), + "other array routes retain the exact-window contract" + ); + } + + #[test] + fn active_blinds_every_truncated_null_update_retains_only_raw_parent() { + let identity = BLINDS; + let bits = one_leaf(11, &unpack(&[0])); + for length in 1..bits.len() { + let truncated = &bits[..length]; + let (records, stats) = export_array( + identity, + &[(11, "CausingActor", 2_370_661_694)], + truncated, + Some(MEASURED_BUILD), + ); + assert_eq!(stats.array.errors, 1, "cut at {length}"); + assert_eq!(records.fields.len(), 1, "cut at {length}"); + assert_eq!(records.fields[0].bit_count, length as u32); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(truncated).as_slice()) + ); + } + } + + #[test] + fn active_blinds_empty_delta_rejects_every_nonzero_trailer_byte() { + for trailer in 1..=255u8 { + let bits = unpack(&[2, 0, trailer]); + let (records, stats) = export_array(BLINDS, &[], &bits, Some(MEASURED_BUILD)); + assert_eq!(stats.array.errors, 1, "trailer {trailer}"); + assert_eq!(stats.active_blinds_empty_trailers, 0, "trailer {trailer}"); + assert_eq!(records.fields.len(), 1); + } + } + + #[test] + fn active_blinds_sparse_updates_keep_indices_and_packed_reference_boundaries() { + let identity = BLINDS; + for reference in [0, 1, 127, 128, 16_383, 16_384, 2_097_151] { + let mut bits = Vec::new(); + bits.int_packed(3); + for index in [0, 2] { + bits.int_packed(index + 1); + bits.int_packed(12); + let mut payload = Vec::new(); + payload.int_packed(reference); + bits.int_packed(payload.len() as u32); + bits.extend(payload); + bits.int_packed(0); + } + bits.int_packed(0); + let (records, stats) = export_array( + identity, + &[(11, "CausingActor", 2_370_661_694)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(stats.array.errors + stats.array_leaf_decode_errors, 0); + assert_eq!(records.fields.len(), 3); + for (row, index) in records.fields[..2].iter().zip([0, 2]) { + assert_eq!( + row.field_name.as_deref(), + Some(format!("ActiveBlinds[{index}].CausingActor").as_str()) + ); + assert_eq!(row.value_i64, Some(i64::from(reference))); + } + assert_eq!( + records.fields[2].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + } + } + + #[test] + fn active_blinds_changed_member_declaration_retains_only_raw_parent() { + let bits = blind_element(8); + let (valid, clean) = export_array(BLINDS, &BLIND_MEMBERS, &bits, Some(MEASURED_BUILD)); + assert_eq!(valid.fields.len(), 10); + assert_eq!(clean.array_leaf_decode_errors, 0); + assert_eq!(valid.fields[0].value_i64, Some(7)); + assert_eq!(valid.fields[8].value_i64, Some(257)); + + let mut changed = BLIND_MEMBERS; + changed[0].2 += 1; + let (refused, stats) = export_array(BLINDS, &changed, &bits, Some(MEASURED_BUILD)); + assert_eq!(refused.fields.len(), 1); + assert_eq!(refused.fields[0].field_name.as_deref(), Some(BLINDS.1)); + assert_eq!( + refused.fields[0].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + assert_eq!(stats.array_leaf_decode_errors, 1); + } + + /// `EffectID` is an `int64` (see `LEAF_RULES`): bit 63 set is a + /// negative ID, not an overflow a `UInt64` read would refuse. + #[test] + fn active_blinds_effect_id_is_signed() { + let (valid, stats) = export_array( + BLINDS, + &BLIND_MEMBERS, + &blind_element(-2), + Some(MEASURED_BUILD), + ); + assert_eq!(stats.array_leaf_decode_errors, 0); + let effect_id = valid + .fields + .iter() + .find(|f| f.field_name.as_deref() == Some("ActiveBlinds[0].EffectID")) + .expect("the EffectID leaf is emitted"); + assert_eq!(effect_id.value_i64, Some(-2)); + assert_eq!(effect_id.bit_count, 64); + } + + #[test] + fn nested_array_identity_and_overlay_disagreements_are_refused() { + let kill = MeasuredArrayRoute::KillData; + let container = |name, checksum, resolved| typed(kill, 6, name, checksum, resolved); + assert_eq!( + container("AssistingPlayers", 1_689_463_717, None), + Some(Leaf::Nested) + ); + assert_eq!(container("Other", 1_689_463_717, None), None); + assert_eq!(container("AssistingPlayers", 0, None), None); + for blocked in [FieldType::ObjectNetGuid, FieldType::Raw, FieldType::Skip] { + assert_eq!( + container("AssistingPlayers", 1_689_463_717, Some(blocked)), + None + ); + } + for blocked in [FieldType::Float, FieldType::Raw, FieldType::Skip] { + let member = leaf_rule( + NESTED_RULES, + MeasuredArrayRoute::SelectedV2, + 14, + 0, + Some("SocketAsset"), + Some(3_666_994_016), + Some(blocked), + ); + assert_eq!(member, None, "{blocked:?}"); + } + } + + #[test] + fn malformed_nested_value_is_transactional_and_keeps_outer_raw() { + let malformed = one_element(&[(7, unpack(&[2])), (7, unpack(&[1]))]); + let (records, stats) = export_array( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + &[ + (6, "AssistingPlayers", 1_689_463_717), + (7, "AssistingPlayers", 1_417_448_159), + ], + &one_leaf(6, &malformed), + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&malformed).as_slice()) + ); + assert_eq!(records.fields[1].field_name.as_deref(), Some(KILL_PARENT)); + assert_eq!(stats.array_leaf_decode_errors, 1); + assert_eq!( + stats.array.fields_emitted, 3, + "walker count includes attempted leaves, while no row prefix leaks" + ); + + let valid = one_element(&[(14, unpack(&[2])), (15, unpack(&[4]))]); + let (records, stats) = export_array( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + &[ + (13, "EquippableAttachments", 3_137_596_882), + (14, "SocketAsset", 3_666_994_016), + (15, "AttachmentAsset", 0), + ], + &one_leaf(13, &valid), + Some(MEASURED_BUILD), + ); + assert_eq!( + records.fields.len(), + 2, + "wrong nested checksum emits no prefix" + ); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(&valid).as_slice()) + ); + assert_eq!(stats.array_leaf_decode_errors, 1); + } + + /// Only on its measured group, parent, checksum and branch; anywhere else + /// the literal is an ordinary raw field. + #[test] + fn tracked_rewards_literal_opaque_empty_variant_keeps_only_parent_raw() { + let bits = unpack(&[0x02, 0x00, 0x00]); + let (o, p, c) = (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM); + let b = Some(MEASURED_BUILD); + for (identity, branch) in [ + ((o, p, c + 1), b), + (("/Script/ShooterGame.Other", p, c), b), + ((o, "OtherRewards", c), b), + ((o, p, c), None), + ] { + let (records, stats) = export_array(identity, &[(49, "Rewards", 0)], &bits, branch); + let at = format!("{identity:?} {branch:?}"); + assert_eq!(records.fields.len(), 1, "{at}"); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 0, "{at}"); + } + let (records, stats) = export_array((o, p, c), &[(49, "Rewards", 0)], &bits, b); + assert_eq!(records.fields.len(), 1); + assert_eq!(records.fields[0].field_name.as_deref(), Some(p)); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some([2, 0, 0].as_slice()) + ); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 1); + assert_eq!(stats.array.fields_emitted, 0); + } + + /// A different trailing byte, a nonempty extension, or no zero index + /// terminator: none becomes an accepted optional trailer, and each keeps + /// the exact decoder's diagnostic. + #[test] + fn tracked_rewards_residual_variants_keep_exact_diagnostics() { + for bits in [ + unpack(&[0x02, 0x00, 0x01]), + unpack(&[0x02, 0x00, 0x00, 0x00]), + unpack(&[0x02]), + ] { + let (records, stats) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(49, "Rewards", 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 0); + assert_eq!(stats.array.fields_emitted, 0); + assert!( + stats.array.unconsumed_root_bits > 0 + || stats.array.errors > 0 + || stats.array.implicit_terminations > 0, + "residual window lost its exact-decoder diagnostic: {stats:?}" + ); + } + // A complete nonempty array followed by a zero byte is not the measured + // empty variant. Walking a child does not authorize emitting it when + // the enclosing array retains unexplained bits. + let mut bits = one_leaf(19, &[false; 32]); + bits.extend(unpack(&[0])); + let (records, stats) = export_array( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + &[(19, "Rewards", 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 0); + assert_eq!(stats.array.fields_emitted, 1); + assert_eq!(stats.array.unconsumed_root_bits, 8); + } + + #[test] + fn measured_effect_and_ignore_routes_emit_expected_leaf_values() { + let payload: Vec = (0..32) + .map(|bit| (-0.0f32).to_bits() & (1 << bit) != 0) + .collect(); + let bits = one_leaf(33, &payload); + let (records, _) = export_array( + (EFFECTS_GROUP, "ServerActiveEffects", 3_301_618_856), + &[(33, "StartTimeStamp", 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + assert_eq!( + records.fields[0].value_f64.unwrap().to_bits(), + (-0.0f64).to_bits() + ); + + let mut payload = Vec::new(); + payload.int_packed(700); + let bits = one_leaf(5, &payload); + let (records, _) = export_array( + (IGNORE_GROUP, "RequestedIgnoreActors", 1_063_739_204), + &[(5, "RequestedIgnoreActors", 3_344_674_359)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + let child = &records.fields[0]; + assert_eq!(child.raw_bits.as_deref(), Some(pack(&payload).as_slice())); + assert_eq!(values(child), (Some(700), None, None, None)); + } + + #[test] + fn existing_ability_array_keeps_its_float_type_without_measured_route() { + let payload: Vec = (0..32) + .map(|bit| 12.5f32.to_bits() & (1 << bit) != 0) + .collect(); + let bits = one_leaf(7, &payload); + let (records, _) = export_array( + ( + "/Game/Characters/_Core/Comp_AbilityStatisticsReplicator.Comp_AbilityStatisticsReplicator_C", + "AbilityCastsThisRound", + 0, + ), + &[(7, "CastTime_4_5AE288704801A9B74D6D159DFC2BD147", 0)], + &bits, + None, + ); + assert_eq!(records.fields.len(), 2); + assert_eq!(records.fields[0].value_f64, Some(12.5)); + assert_eq!( + records.fields[0].field_name.as_deref(), + Some("AbilityCastsThisRound[0].CastTime_4_5AE288704801A9B74D6D159DFC2BD147") + ); + // Elsewhere its handles mean something else: one raw row. + let (records, _) = export_array( + ( + "/Script/ShooterGame.SomeOtherComponent", + "AbilityCastsThisRound", + 0, + ), + &[(7, "CastTime_4_5AE288704801A9B74D6D159DFC2BD147", 0)], + &bits, + None, + ); + assert_eq!(records.fields.len(), 1); + } + + /// A route's identity, child declaration and a one-leaf array it types. + type Case = ( + (&'static str, &'static str, u32), + (u32, &'static str, u32), + Vec, + ); + + /// One typed case per flattened route. No wildcard: a new route does not + /// compile until it has one. + fn case_for(route: MeasuredArrayRoute) -> Option { + let mut reference = Vec::new(); + reference.int_packed(5); + let case = match route { + MeasuredArrayRoute::KillData => ( + (KILL_GROUP, KILL_PARENT, KILL_CHECKSUM), + (3, "Victim", 3_990_035_472), + one_leaf(3, &reference), + ), + MeasuredArrayRoute::SelectedV2 => ( + (SELECTED_GROUP, SELECTED_PARENT, SELECTED_CHECKSUM), + (3, "EquippableDataAsset", 1_793_937_854), + one_leaf(3, &reference), + ), + MeasuredArrayRoute::TrackedRewards => ( + (OWNER, REWARDS_PARENT, REWARDS_CHECKSUM), + (30, "InstancesOfReward", 2_922_243_316), + one_leaf(30, &[false; 32]), + ), + MeasuredArrayRoute::ActiveBlinds => ( + BLINDS, + (11, "CausingActor", 2_370_661_694), + one_leaf(11, &unpack(&[0])), + ), + MeasuredArrayRoute::ServerActiveEffects => ( + (EFFECTS_GROUP, "ServerActiveEffects", 3_301_618_856), + (33, "StartTimeStamp", 0), + one_leaf(33, &unpack(&1.5f32.to_le_bytes())), + ), + MeasuredArrayRoute::RequestedIgnoreActors => ( + (IGNORE_GROUP, "RequestedIgnoreActors", 1_063_739_204), + (5, "RequestedIgnoreActors", 3_344_674_359), + one_leaf(5, &reference), + ), + MeasuredArrayRoute::AllPlayersObfuscatedPlayerInformation => ( + (OWNER, OWNER_PARENT, OWNER_CHECKSUM), + (49, "bIsAfk", 0), + one_leaf(49, &[true]), + ), + // An RPC parameter, not a flattened property; its gate is + // `projectile_path_rpc_expands_only_on_admitting_branches`. + MeasuredArrayRoute::NetworkedProjectilePath => return None, + }; + Some(case) + } + + /// The gate is per route, not per build: a typed child (a leaf the 13.05 + /// route types) appears exactly where the branch admits its route. Admission + /// and the exact walker both take the route from `measured_array_route`, so + /// this checks each parent reads its own route's bit: every flattened route + /// has a case (no wildcard), every supported branch runs plus none, against + /// `MeasuredArrayRoutes::for_branch`. Two pairs share their branches + /// (PlayerInfo/TrackedRewards, KillData/RequestedIgnoreActors), so only the + /// identity pin below sees a swap inside one. + #[test] + fn legacy_branches_expand_only_their_admitted_routes() { + // The identity pin: each case must map to its own route. + for route in MeasuredArrayRoute::ALL { + if let Some(((group, parent, checksum), _, _)) = case_for(route) { + assert_eq!( + measured_array_route(group, parent, Some(checksum)), + Some(route), + "{route:?}" + ); + } + } + let branches = vrf_transform::ALL_VERSIONS + .iter() + .map(|version| Some(version.branch())) + .chain([None]); + let mut expanded = Vec::new(); + for branch in branches { + let admitted = + branch.map_or(MeasuredArrayRoutes::NONE, MeasuredArrayRoutes::for_branch); + for route in MeasuredArrayRoute::ALL { + let Some((identity, leaf, bits)) = case_for(route) else { + continue; + }; + let (records, stats) = export_array(identity, &[leaf], &bits, branch); + let at = format!("{branch:?} {route:?}"); + // One child, counted on its own route alone. The fields are read + // directly, in `ALL` order: `route_children` would read back any + // swap of its own arms as consistent. + let counted = [ + stats.route_children_player_information, + stats.route_children_tracked_rewards, + stats.route_children_selected_v2, + stats.route_children_kill_data, + stats.route_children_server_active_effects, + stats.route_children_requested_ignore_actors, + stats.route_children_active_blinds, + stats.route_children_projectile_path, + ]; + let want = MeasuredArrayRoute::ALL + .map(|r| u64::from(r == route && admitted.admits(route))); + assert_eq!(counted, want, "{at}"); + let parent = records.fields.last().unwrap(); + assert_eq!(parent.field_name.as_deref(), Some(identity.1), "{at}"); + assert_eq!(parent.raw_bits.as_deref(), Some(pack(&bits).as_slice())); + assert_eq!(stats.array_leaf_decode_errors, 0, "{at}"); + if admitted.admits(route) { + if !expanded.contains(&route) { + expanded.push(route); + } + assert_eq!(records.fields.len(), 2, "{at}"); + let child = &records.fields[0]; + assert!( + child.value_i64.is_some() + || child.value_f64.is_some() + || child.value_bool.is_some(), + "{at}: the admitted child is typed" + ); + } else { + assert_eq!(records.fields.len(), 1, "{at}"); + assert_eq!(stats.array.fields_emitted, 0, "{at}"); + } + } + } + // Not vacuous: each of the seven flattened routes expanded on some + // branch, and the run with no branch kept every one of them raw. + assert_eq!(expanded.len(), 7, "{expanded:?}"); + } + + /// Every flattened route keeps only its raw parent when the group, parent + /// or checksum differs, and when its exact window has 8 bits too many or + /// too few, which must also move a walker diagnostic. + #[test] + fn measured_routes_refuse_changed_identity_and_inexact_windows() { + for route in MeasuredArrayRoute::ALL { + let Some(((group, parent, checksum), leaf, valid)) = case_for(route) else { + continue; + }; + let other_group = format!("{group}Other"); + let mut extra = valid.clone(); + extra.extend([false; 8]); + let cut = valid[..valid.len() - 8].to_vec(); + for (identity, bits, inexact) in [ + ((other_group.as_str(), parent, checksum), &valid, false), + ((group, "Other", checksum), &valid, false), + ((group, parent, checksum + 1), &valid, false), + ((group, parent, checksum), &extra, true), + ((group, parent, checksum), &cut, true), + ] { + let (records, stats) = export_array(identity, &[leaf], bits, Some(MEASURED_BUILD)); + let at = format!("{route:?} {identity:?} {} bits", bits.len()); + assert_eq!(records.fields.len(), 1, "{at}"); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(pack(bits).as_slice()), + "{at}" + ); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 0, "{at}"); + assert_eq!(stats.active_blinds_empty_trailers, 0, "{at}"); + if inexact { + assert!( + stats.array.unconsumed_root_bits > 0 + || stats.array.errors > 0 + || stats.array.implicit_terminations > 0, + "{at}: {stats:?}" + ); + } else { + assert_eq!(stats.array.fields_emitted, 0, "{at}"); + } + } + } + } + + /// A leaf no rule types stays an exact raw child on every admitted route + /// (ActiveBlinds refuses the whole array instead), beside its raw parent. + #[test] + fn an_untyped_leaf_on_an_admitted_route_stays_raw() { + for route in MeasuredArrayRoute::ALL { + let Some((identity, _, _)) = case_for(route) else { + continue; + }; + if route == MeasuredArrayRoute::ActiveBlinds { + continue; + } + let bits = one_leaf(19, &[true, false, true]); + let (records, stats) = export_array( + identity, + &[(19, "Unlisted", 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2, "{route:?}"); + let child = &records.fields[0]; + assert_eq!(child.raw_bits.as_deref(), Some([5].as_slice()), "{route:?}"); + assert_eq!(values(child), (None, None, None, None), "{route:?}"); + assert_eq!( + records.fields[1].raw_bits.as_deref(), + Some(pack(&bits).as_slice()) + ); + assert_eq!(stats.array.fields_emitted, 1, "{route:?}"); + assert_eq!(stats.tracked_rewards_opaque_empty_variants, 0); + } + } + + #[test] + fn new_routes_type_only_the_verified_leaf_windows() { + let effects = |handle, resolved| { + leaf_rule( + LEAF_RULES, + MeasuredArrayRoute::ServerActiveEffects, + handle, + 0, + None, + None, + resolved, + ) + }; + assert_eq!( + effects(33, Some(FieldType::Float)), + Some(Leaf::Field(FieldType::Float)) + ); + assert_eq!(effects(33, Some(FieldType::Double)), None); + } + + #[test] + fn requested_ignore_actor_requires_exact_child_identity_and_non_raw_resolution() { + let ignore = MeasuredArrayRoute::RequestedIgnoreActors; + assert_eq!( + typed(ignore, 5, "RequestedIgnoreActors", 3_344_674_359, None), + Some(Leaf::Field(FieldType::ObjectNetGuid)) + ); + for (handle, name, checksum, resolved) in [ + ( + 5, + "RequestedIgnoreActors", + 3_344_674_359, + Some(FieldType::Raw), + ), + ( + 5, + "RequestedIgnoreActors", + 3_344_674_359, + Some(FieldType::Skip), + ), + ( + 5, + "RequestedIgnoreActors", + 3_344_674_359, + Some(FieldType::Int32), + ), + (5, "Other", 3_344_674_359, None), + (5, "RequestedIgnoreActors", 0, None), + (4, "RequestedIgnoreActors", 3_344_674_359, None), + ] { + assert_eq!(typed(ignore, handle, name, checksum, resolved), None); + } + } + + #[test] + fn requested_ignore_actor_bad_packed_child_keeps_raw_rows_and_counts_error() { + // 0xff is an unterminated IntPacked value: its continuation bit is + // set, but the exact leaf window ends before another packed byte. + let bits = one_leaf(5, &[true; 8]); + let (records, stats) = export_array( + (IGNORE_GROUP, "RequestedIgnoreActors", 1_063_739_204), + &[(5, "RequestedIgnoreActors", 3_344_674_359)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + let child = &records.fields[0]; + assert_eq!(child.raw_bits.as_deref(), Some([0xff].as_slice())); + assert_eq!(values(child), (None, None, None, None)); + let parent = &records.fields[1]; + assert_eq!(parent.raw_bits.as_deref(), Some(pack(&bits).as_slice())); + assert_eq!(stats.array_leaf_decode_errors, 1); + } + + /// `FloatValues` (9) and `ObjectValues` (17) are effect arrays: the effect + /// decoder's JSON, only under the declared name and checksum, and a blob + /// it refuses counts as a leaf error. + #[test] + fn measured_effect_value_arrays_render_as_effect_json() { + let float = unpack(&[ + 2, 2, 0x10, 0x20, 0x39, 4, 0x12, 0x40, 0, 0, 0x80, 0x3f, 0, 0, + ]); + let object = unpack(&[2, 2, 0x20, 0x20, 0x37, 4, 0x22, 0x20, 0x1d, 0x30, 0, 0]); + let identity = (EFFECTS_GROUP, "ServerActiveEffects", 3_301_618_856); + for (handle, name, checksum, payload, want, errors) in [ + ( + 9, + "FloatValues", + 3_597_032_544, + &float, + Some("[{\"tag\":284,\"value\":1}]"), + 0, + ), + ( + 17, + "ObjectValues", + 865_691_585, + &object, + Some("[{\"tag\":283,\"value\":3086}]"), + 0, + ), + (9, "FloatValues", 0, &float, None, 0), + (17, "ObjectValues", 865_691_585, &float, None, 1), + ] { + let bits = one_leaf(handle, payload); + let leaves = [(handle, name, checksum)]; + let (records, stats) = export_array(identity, &leaves, &bits, Some(MEASURED_BUILD)); + let child = &records.fields[0]; + assert_eq!(child.value_str.as_deref(), want, "{name} {checksum}"); + assert_eq!(child.raw_bits.as_deref(), Some(pack(payload).as_slice())); + assert_eq!(stats.array_leaf_decode_errors, errors, "{name} {checksum}"); + } + } + + #[test] + fn measured_effect_vectors_are_typed_without_a_top_level_overlay() { + let payload: Vec = [1.25f64, -0.0, -2.5] + .iter() + .flat_map(|value| (0..64).map(move |bit| value.to_bits() & (1 << bit) != 0)) + .collect(); + for (handle, name) in [(30, "Translation"), (31, "Scale3D")] { + let bits = one_leaf(handle, &payload); + let (records, _) = export_array( + (EFFECTS_GROUP, "ServerActiveEffects", 3_301_618_856), + &[(handle, name, 0)], + &bits, + Some(MEASURED_BUILD), + ); + assert_eq!(records.fields.len(), 2); + assert_eq!( + records.fields[0].value_str.as_deref(), + Some("(1.25,-0,-2.5)") + ); + } + let effects = MeasuredArrayRoute::ServerActiveEffects; + let member = Some("DifferentMember"); + assert_eq!( + leaf_rule(LEAF_RULES, effects, 30, 192, member, None, None), + None + ); + } +} diff --git a/native/replay/src/vendor/sink/failure_stats.rs b/native/replay/src/vendor/sink/failure_stats.rs new file mode 100644 index 000000000..caf46b99b --- /dev/null +++ b/native/replay/src/vendor/sink/failure_stats.rs @@ -0,0 +1,409 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/failure_stats.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! The opt-in, bounded aggregate of stream failures. Only `diag` enables and +//! reads it; it feeds no verdict, summary counter or table row. +//! +//! [`ChannelState::push_stream_failure`](super::ChannelState::push_stream_failure) +//! keeps 32 lines, which every corpus replay fills from the match's opening +//! seconds: a biased sample. This counts every failure, one cell per (kind, +//! cause, group path, function count, handle, consumed bits, preservation): +//! totals exact, cells and payload samples capped against malformed input. +//! +//! Invariants, per pass: +//! +//! 1. `total_failures == field_stream_failures + rpc_stream_failures`: every +//! site that bumps either also calls [`FailureAggregate::note_failure`] +//! (`framing.rs` in `vrf-net`, `stream.rs` here), so a mismatch is a wiring +//! bug, not sampling noise. +//! 2. `preserved_unresolved` (every failure whose whole stream reached a raw +//! preservation row, post-RepLayout tails included) is a subset of +//! `total_failures`, reconciles with `unresolved_rpc_payloads_preserved` and +//! is never loss: `real_loss() == total_failures - preserved_unresolved`. +//! +//! `diag` prints both as `reconciled` ([`FailureAggregate::reconciles`]). + +use std::sync::Arc; + +use vrf_net::pipeline::{StreamFailure, StreamFailureCause, StreamKind}; +use vrf_net::stats::NetStats; +use vrf_schema::FxHashMap; + +/// Detailed failure records retained per cell. Counts continue after sample +/// retention stops. +pub const MAX_SAMPLES_PER_CELL: usize = 3; + +/// Distinct keyed cells retained per pass; past it exact totals continue in +/// `overflow`, so hostile group paths cannot grow the map without bound. +pub const MAX_FAILURE_CELLS: usize = 4096; + +/// Longest payload a sample keeps whole, in bytes; a longer one keeps this +/// prefix, flagged `payload_truncated`. +pub const MAX_SAMPLE_PAYLOAD_BYTES: usize = 96; + +/// One aggregate cell: every failure sharing one key. +#[derive(Debug, Default, Clone)] +pub struct FailureCell { + pub count: u64, + pub bit_count_total: u64, + pub consumed_bits_total: u64, + pub abandoned_bits_total: u64, + pub samples: Vec, +} + +impl FailureCell { + /// Add `other`'s count and sums; samples are the caller's business. + fn add(&mut self, other: &Self) { + self.count += other.count; + self.bit_count_total += other.bit_count_total; + self.consumed_bits_total += other.consumed_bits_total; + self.abandoned_bits_total += other.abandoned_bits_total; + } +} + +/// One representative failure: its cell's key holds the consumed bits and +/// preservation it shares; `payload_hex` is the block's decoded bytes, up to +/// [`MAX_SAMPLE_PAYLOAD_BYTES`] (always `Some`: samples come only with one). +#[derive(Debug, Clone)] +pub struct FailureSample { + pub actor_net_guid: u32, + pub bit_count: u32, + pub abandoned_bits: u64, + pub record_offset: Option, + pub payload_hex: Option, + pub payload_truncated: bool, +} + +/// The dimensions every failure is aggregated under. `consumed_bits` is a key, +/// not a sum: it separates a drift that always stops at one offset (a field +/// stream consuming 185 of 200 bits) from random offsets, which a sum would +/// average away. A group fails at very few distinct offsets, so cells stay few. +/// Ordered field by field, for a deterministic tie-break. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FailureKey { + pub kind: StreamKind, + pub cause: StreamFailureCause, + /// As the resolver left it -- a bare instance name or `` + /// stays as-is, never guessed into a class. + pub group_path: Arc, + /// The function count the RPC handle read used (0 for RepLayout and for + /// unresolved groups). + pub function_count: u32, + /// Handle of the failing record, when the walk tracked one. + pub record_handle: Option, + pub consumed_bits: u64, + /// Whether every bit in the failed stream reached a raw preservation row. + pub payload_preserved: bool, +} + +impl FailureKey { + fn new(failure: &StreamFailure, group_path: Arc) -> Self { + Self { + kind: failure.kind, + cause: failure.cause, + group_path, + function_count: failure.function_count, + record_handle: failure.record_handle, + consumed_bits: failure.consumed_bits, + payload_preserved: failure.payload_preserved, + } + } +} + +/// The bounded aggregate for one pass (main ReplayData or checkpoint). +#[derive(Debug, Clone)] +pub struct FailureAggregate { + cells: FxHashMap, + /// Failures whose key arrived after the distinct-cell cap was reached. + overflow: FailureCell, + /// The two reconciled totals; see the module doc. + total_failures: u64, + preserved_unresolved: u64, + /// Whether decoded payload bytes may be retained in bounded samples. + retain_payloads: bool, +} + +impl Default for FailureAggregate { + fn default() -> Self { + Self::new(false) + } +} + +impl FailureAggregate { + /// Payload retention is off unless the caller opts in; all counters and + /// non-payload dimensions remain either way. + pub fn new(retain_payloads: bool) -> Self { + Self { + cells: FxHashMap::default(), + overflow: FailureCell::default(), + total_failures: 0, + preserved_unresolved: 0, + retain_payloads, + } + } + + /// Count one failure. Samples come only from [`Self::note_payload`], whose + /// callers hold the decoded bytes. Preservation is read from `failure`, so + /// the key and the reconciled counter cannot disagree. + pub fn note_failure(&mut self, failure: &StreamFailure, group_path: Arc) { + let key = FailureKey::new(failure, group_path); + let cell = Self::cell(&mut self.cells, key).unwrap_or(&mut self.overflow); + cell.count += 1; + cell.bit_count_total += u64::from(failure.bit_count); + cell.consumed_bits_total += failure.consumed_bits; + cell.abandoned_bits_total += failure.remaining_bits; + self.total_failures += 1; + if failure.payload_preserved { + self.preserved_unresolved += 1; + } + } + + /// Attach one real-payload sample to a cell, from the three payload + /// callbacks framing makes after a block's `on_stream_failure`. Never + /// counts: that is one [`Self::note_failure`] per failure. + pub fn note_payload(&mut self, failure: &StreamFailure, group_path: Arc, payload: &[u8]) { + if !self.retain_payloads { + return; + } + let Some(cell) = Self::cell(&mut self.cells, FailureKey::new(failure, group_path)) else { + return; + }; + if cell.samples.len() >= MAX_SAMPLES_PER_CELL { + return; + } + let take = payload.len().min(MAX_SAMPLE_PAYLOAD_BYTES); + cell.samples.push(FailureSample { + actor_net_guid: failure.actor_net_guid.0, + bit_count: failure.bit_count, + abandoned_bits: failure.remaining_bits, + record_offset: failure.record_offset, + payload_hex: Some(hex(&payload[..take])), + payload_truncated: payload.len() > MAX_SAMPLE_PAYLOAD_BYTES, + }); + } + + /// The cell for `key`, created while the map is under the distinct-cell + /// cap; `None` for a new key once it is full. + fn cell( + cells: &mut FxHashMap, + key: FailureKey, + ) -> Option<&mut FailureCell> { + if cells.len() < MAX_FAILURE_CELLS || cells.contains_key(&key) { + Some(cells.entry(key).or_default()) + } else { + None + } + } + + /// Merge another pass's aggregate in (each checkpoint chunk has its own + /// channel state): counts and sums add, samples fill up to the cap. + pub fn absorb(&mut self, other: &mut Self) { + self.total_failures += other.total_failures; + self.preserved_unresolved += other.preserved_unresolved; + self.overflow.add(&other.overflow); + let mut other_cells: Vec<_> = other.cells.drain().collect(); + other_cells.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, mut other_cell) in other_cells { + let Some(cell) = Self::cell(&mut self.cells, key) else { + self.overflow.add(&other_cell); + continue; + }; + cell.add(&other_cell); + let take = if self.retain_payloads { + MAX_SAMPLES_PER_CELL + .saturating_sub(cell.samples.len()) + .min(other_cell.samples.len()) + } else { + 0 + }; + cell.samples.extend(other_cell.samples.drain(..take)); + } + } + + /// Every failure counted (invariant 1). + pub fn total_failures(&self) -> u64 { + self.total_failures + } + + /// The wholly preserved subset (invariant 2). + pub fn preserved_unresolved(&self) -> u64 { + self.preserved_unresolved + } + + /// Failures not wholly preserved (invariant 2). A block count: earlier + /// fields of a failed block may still have emitted rows. + pub fn real_loss(&self) -> u64 { + self.total_failures - self.preserved_unresolved + } + + /// Whether both invariants hold against the same pass's `NetStats`. + pub fn reconciles(&self, net: &NetStats) -> bool { + self.total_failures == net.field_stream_failures + net.rpc_stream_failures + && self.preserved_unresolved == net.unresolved_rpc_payloads_preserved + } + + /// Failures counted exactly but omitted from keyed cells after the cap. + pub fn overflow(&self) -> &FailureCell { + &self.overflow + } + + /// Whether raw decoded payload bytes were explicitly enabled. + pub fn retains_payloads(&self) -> bool { + self.retain_payloads + } + + /// The cells by count descending, then key ascending, so ties in count + /// cannot shuffle between runs. + pub fn cells_sorted(&self) -> Vec<(&FailureKey, &FailureCell)> { + let mut cells: Vec<(&FailureKey, &FailureCell)> = self.cells.iter().collect(); + cells.sort_by(|(a, ac), (b, bc)| bc.count.cmp(&ac.count).then_with(|| a.cmp(b))); + cells + } +} + +/// Lowercase hex without separators. +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use vrf_net::types::NetworkGuid; + + fn failure(kind: StreamKind, cause: StreamFailureCause, consumed: u64) -> StreamFailure { + StreamFailure { + kind, + actor_net_guid: NetworkGuid(7), + bit_count: 200, + function_count: 0, + consumed_bits: consumed, + remaining_bits: 200 - consumed, + cause, + record_handle: Some(3), + record_offset: Some(consumed), + payload_preserved: false, + } + } + + /// A RepLayout failure and a genuinely lost RPC failure are both real + /// loss, separated by kind. + #[test] + fn real_loss_keeps_rep_layout_and_rpc_apart() { + let mut agg = FailureAggregate::default(); + let field = failure( + StreamKind::RepLayout, + StreamFailureCause::AbandonedTail, + 185, + ); + let rpc = failure(StreamKind::Rpc, StreamFailureCause::ReadError, 0); + agg.note_failure( + &field, + Arc::from("/Script/ShooterGame.AresAbilitySystemComponent"), + ); + agg.note_failure(&rpc, Arc::from("SomeUnresolved")); + assert_eq!(agg.total_failures(), 2); + assert_eq!(agg.preserved_unresolved(), 0); + assert_eq!(agg.real_loss(), 2); + let cells = agg.cells_sorted(); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].0.kind, StreamKind::RepLayout); + assert_eq!(cells[0].0.cause, StreamFailureCause::AbandonedTail); + assert_eq!(cells[1].0.kind, StreamKind::Rpc); + assert_eq!(cells[1].0.cause, StreamFailureCause::ReadError); + } + + /// Both invariants against the pass's `NetStats`: a failure framing + /// counted but the aggregate missed, or a preservation it did not see, + /// is a wiring bug and reads `false`. + #[test] + fn reconciles_checks_both_invariants_against_net_stats() { + let mut agg = FailureAggregate::default(); + let mut rpc = failure(StreamKind::Rpc, StreamFailureCause::ReadError, 0); + rpc.payload_preserved = true; + let field = failure(StreamKind::RepLayout, StreamFailureCause::ReadError, 9); + agg.note_failure(&field, "A".into()); + agg.note_failure(&rpc, "B".into()); + let net = |field, rpc, preserved| NetStats { + field_stream_failures: field, + rpc_stream_failures: rpc, + unresolved_rpc_payloads_preserved: preserved, + ..NetStats::default() + }; + assert!(agg.reconciles(&net(1, 1, 1))); + for wrong in [net(1, 2, 1), net(0, 1, 1), net(1, 1, 0)] { + assert!(!agg.reconciles(&wrong)); + } + } + + /// Absorb adds counts and moves samples up to the cap, so a checkpoint + /// pass merged into a main pass cannot duplicate or lose a total. + #[test] + fn absorb_adds_counts_and_fills_samples() { + let path = Arc::from("/Script/ShooterGame.X"); + let f = failure( + StreamKind::RepLayout, + StreamFailureCause::AbandonedTail, + 100, + ); + let mut main = FailureAggregate::new(true); + for _ in 0..4 { + main.note_payload(&f, Arc::clone(&path), &[0xAA]); + main.note_failure(&f, Arc::clone(&path)); + } + let mut cp = FailureAggregate::new(true); + for _ in 0..6 { + cp.note_payload(&f, Arc::clone(&path), &[0xBB]); + cp.note_failure(&f, Arc::clone(&path)); + } + main.absorb(&mut cp); + assert_eq!(main.total_failures(), 10); + assert_eq!(main.real_loss(), 10); + let (_, cell) = &main.cells_sorted()[0]; + assert_eq!(cell.count, 10); + assert_eq!(cell.samples.len(), MAX_SAMPLES_PER_CELL); + assert!(cp.cells_sorted().is_empty(), "absorb drains the source"); + } + + /// A payload up to the cap is kept whole; any longer one, however long, + /// keeps the cap's prefix and says so. + #[test] + fn a_long_payload_keeps_a_flagged_prefix() { + let mut agg = FailureAggregate::new(true); + let f = failure(StreamKind::Rpc, StreamFailureCause::ReadError, 0); + for len in [MAX_SAMPLE_PAYLOAD_BYTES, MAX_SAMPLE_PAYLOAD_BYTES + 1, 2000] { + agg.note_payload(&f, Arc::from("Group"), &vec![0x7a; len]); + } + let samples = &agg.cells[&FailureKey::new(&f, Arc::from("Group"))].samples; + let kept: Vec<_> = (samples.iter()) + .map(|s| (s.payload_hex.as_deref(), s.payload_truncated)) + .collect(); + let prefix = "7a".repeat(MAX_SAMPLE_PAYLOAD_BYTES); + let prefix = Some(prefix.as_str()); + assert_eq!(kept, [(prefix, false), (prefix, true), (prefix, true)]); + } + + #[test] + fn default_does_not_retain_payloads() { + let mut agg = FailureAggregate::default(); + let f = failure(StreamKind::Rpc, StreamFailureCause::ReadError, 0); + agg.note_payload(&f, Arc::from("Group"), &[0xAA]); + agg.note_failure(&f, Arc::from("Group")); + assert!(agg.cells_sorted()[0].1.samples.is_empty()); + } + + #[test] + fn distinct_cell_cap_preserves_exact_totals_in_overflow() { + let mut agg = FailureAggregate::default(); + let f = failure(StreamKind::RepLayout, StreamFailureCause::ReadError, 0); + for index in 0..=MAX_FAILURE_CELLS { + agg.note_failure(&f, Arc::from(format!("Group{index}"))); + } + + assert_eq!(agg.total_failures(), (MAX_FAILURE_CELLS + 1) as u64); + assert_eq!(agg.cells_sorted().len(), MAX_FAILURE_CELLS); + assert_eq!(agg.overflow().count, 1); + let retained: u64 = agg.cells_sorted().iter().map(|(_, cell)| cell.count).sum(); + assert_eq!(retained + agg.overflow().count, agg.total_failures()); + } +} diff --git a/native/replay/src/vendor/sink/intern.rs b/native/replay/src/vendor/sink/intern.rs new file mode 100644 index 000000000..3cda02c15 --- /dev/null +++ b/native/replay/src/vendor/sink/intern.rs @@ -0,0 +1,110 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/intern.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! A string pool for the two name columns of `fields.parquet`: a row's name +//! costs a refcount, not an allocation (the lookup still hashes), and buffered +//! rows share one copy. Arrow sees only `&str`, so the Parquet bytes do not +//! depend on it. Counts: docs/PERFORMANCE_NOTES.md#name-interning. + +use std::fmt::Write as _; +use std::sync::Arc; + +use vrf_schema::FxHashSet; + +/// Ceiling on pooled names, a bound on wire-driven input: an unnamed RPC +/// parameter is `"{function}._h{handle}"` with the handle off the wire. Past the +/// cap `intern` still returns a correct, unshared `Arc`. The reference +/// replay pools 4,557 names. +const MAX_POOLED_NAMES: usize = 65_536; + +/// Pool of interned names, plus the scratch buffer [`Self::intern_fmt`] builds +/// them in so no caller allocates a `String` only to throw it away. +#[derive(Debug, Clone, Default)] +pub struct NameInterner { + pool: FxHashSet>, + scratch: String, +} + +impl NameInterner { + /// Pool `s` and return the shared handle. + pub fn intern(&mut self, s: &str) -> Arc { + pooled(&mut self.pool, s) + } + + /// Build a name with `f` in the scratch buffer, then pool it: the + /// allocation-free path for the composed names (RPC parameters, array + /// leaves, struct-blob members), which are the majority of rows. + pub fn intern_fmt(&mut self, f: impl FnOnce(&mut String)) -> Arc { + let Self { pool, scratch } = self; + scratch.clear(); + f(scratch); + pooled(pool, scratch) + } + + /// Number of distinct names currently pooled. Diagnostic only. + #[cfg(test)] + pub fn len(&self) -> usize { + self.pool.len() + } +} + +/// The pooled handle for `s`, pooled now if it is new and the pool is under +/// [`MAX_POOLED_NAMES`]. +fn pooled(pool: &mut FxHashSet>, s: &str) -> Arc { + if let Some(existing) = pool.get(s) { + return Arc::clone(existing); + } + let interned: Arc = Arc::from(s); + if pool.len() < MAX_POOLED_NAMES { + pool.insert(Arc::clone(&interned)); + } + interned +} + +/// Write `args` into `out`. A `String` write cannot fail, and `let _ = write!` +/// at each call site would read as an ignored error. +pub fn put(out: &mut String, args: std::fmt::Arguments<'_>) { + out.write_fmt(args) + .expect("formatting into a String cannot fail"); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Equal text shares one allocation; `Arc::ptr_eq` is how to observe it. + #[test] + fn equal_names_share_one_allocation() { + let mut interner = NameInterner::default(); + let a = interner.intern("/Script/ShooterGame.ShooterCharacter"); + let b = interner.intern("/Script/ShooterGame.ShooterCharacter"); + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!(interner.len(), 1); + } + + /// The built-in-place path must agree with the plain one, including sharing. + #[test] + fn a_formatted_name_pools_with_its_plain_twin() { + let mut interner = NameInterner::default(); + let plain = interner.intern("Fire.Damage"); + let built = interner.intern_fmt(|out| put(out, format_args!("Fire.{}", "Damage"))); + assert_eq!(&*built, "Fire.Damage"); + assert!(Arc::ptr_eq(&plain, &built)); + assert_eq!(interner.len(), 1); + } + + /// Past the cap the pool stops growing but `intern` still returns the + /// right text: a name storm costs sharing, never correctness. + #[test] + fn the_pool_stops_growing_but_never_stops_being_correct() { + let mut interner = NameInterner::default(); + for i in 0..(MAX_POOLED_NAMES + 64) { + let name = interner.intern_fmt(|out| put(out, format_args!("Fn._h{i}"))); + assert_eq!(&*name, format!("Fn._h{i}")); + } + assert_eq!(interner.len(), MAX_POOLED_NAMES); + // A name minted after the cap is still correct, just unshared. + let over = interner.intern("a name the pool never saw"); + assert_eq!(&*over, "a name the pool never saw"); + } +} diff --git a/native/replay/src/vendor/sink/measured_routes.rs b/native/replay/src/vendor/sink/measured_routes.rs new file mode 100644 index 000000000..6f2ac75b0 --- /dev/null +++ b/native/replay/src/vendor/sink/measured_routes.rs @@ -0,0 +1,283 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/measured_routes.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! Which checksum-gated structured-array routes a replay branch admits. +//! +//! A [`MeasuredArrayRoute`] expands one exact (group, parent, checksum) identity +//! into additive child rows and types children through handle-keyed member +//! tables, so where a build moved a member the route refuses (counted) or +//! leaves children untyped: the gate is per build AND per route. A route is +//! admitted only where it was observed on that build's replays with every array +//! counter at zero and an independent decoder matched every child; unobserved +//! is not verified and stays off. Measurements: docs/LEGACY_BUILD_SUPPORT.md. + +/// One measured structured-array route. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MeasuredArrayRoute { + /// `OwnerExclusivePlayerInfo.AllPlayersObfuscatedPlayerInformation`. + AllPlayersObfuscatedPlayerInformation, + /// `OwnerExclusivePlayerInfo.TrackedRewards`. + TrackedRewards, + /// `PersonalizationComponent.SelectedV2`. + SelectedV2, + /// `PlayerMatchStatsComponent.KillData`. + KillData, + /// `EffectManagerComponent.ServerActiveEffects`. + ServerActiveEffects, + /// `FiniteSpeedMovementComponent.RequestedIgnoreActors`. + RequestedIgnoreActors, + /// `BlindManagerComponent.ActiveBlinds`. + ActiveBlinds, + /// `MulticastSetPath.NetworkedProjectilePath` (an RPC parameter array). + NetworkedProjectilePath, +} + +impl MeasuredArrayRoute { + /// Every route, in declaration order. + pub(super) const ALL: [Self; 8] = [ + Self::AllPlayersObfuscatedPlayerInformation, + Self::TrackedRewards, + Self::SelectedV2, + Self::KillData, + Self::ServerActiveEffects, + Self::RequestedIgnoreActors, + Self::ActiveBlinds, + Self::NetworkedProjectilePath, + ]; + + const fn bit(self) -> u8 { + 1 << (self as u8) + } +} + +/// The set of routes admitted for one replay branch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct MeasuredArrayRoutes(u8); + +impl MeasuredArrayRoutes { + /// No route: every parent stays one raw row, and no child is emitted. + pub(super) const NONE: Self = Self(0); + + /// Every route. + pub(super) const ALL: Self = Self::of(&MeasuredArrayRoute::ALL); + + const fn of(routes: &[MeasuredArrayRoute]) -> Self { + let mut bits = 0; + let mut i = 0; + while i < routes.len() { + bits |= routes[i].bit(); + i += 1; + } + Self(bits) + } + + /// The routes admitted for `branch` (the header's branch string); an unknown + /// branch admits nothing. Pinned by `routes_are_pinned_for_every_supported_branch`. + /// Legacy entries: all 48 11.06-12.09 replays (three per build) and the + /// single public 12.10/12.11/13.00 fixtures, main stream and checkpoints. + /// Held back: + /// + /// - `AllPlayersObfuscatedPlayerInformation`, `TrackedRewards` < 12.04: handles +3. + /// - `SelectedV2` 11.06-11.08: a zero-width `DynamicMappings` at 13 moves the nested array. + /// - `ActiveBlinds`, every legacy build: `SourceID`/`EffectID` swapped through 12.04, + /// 9-bit hardcoded-name `SourceID`s from 12.05; refusals move `array_leaf_decode_errors`. + /// - Any route with no child observed on a build (the path on 11.06, 12.06, 12.07). + pub(super) fn for_branch(branch: &str) -> Self { + use MeasuredArrayRoute::{ + AllPlayersObfuscatedPlayerInformation as PlayerInfo, KillData, + NetworkedProjectilePath as ProjectilePath, RequestedIgnoreActors, SelectedV2, + ServerActiveEffects, TrackedRewards, + }; + match branch { + "++Ares-Core+release-11.06" => { + Self::of(&[KillData, ServerActiveEffects, RequestedIgnoreActors]) + } + "++Ares-Core+release-11.07" | "++Ares-Core+release-11.08" => Self::of(&[ + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]), + "++Ares-Core+release-11.09" + | "++Ares-Core+release-11.10" + | "++Ares-Core+release-11.11" + | "++Ares-Core+release-12.00" + | "++Ares-Core+release-12.01" + | "++Ares-Core+release-12.02" + | "++Ares-Core+release-12.03" => Self::of(&[ + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]), + "++Ares-Core+release-12.04" + | "++Ares-Core+release-12.05" + | "++Ares-Core+release-12.08" + | "++Ares-Core+release-12.09" => Self::of(&[ + PlayerInfo, + TrackedRewards, + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]), + "++Ares-Core+release-12.06" | "++Ares-Core+release-12.07" => Self::of(&[ + PlayerInfo, + TrackedRewards, + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ]), + "++Ares-Core+release-12.10" + | "++Ares-Core+release-12.11" + | "++Ares-Core+release-13.00" => { + Self::of(&[PlayerInfo, TrackedRewards, SelectedV2, ServerActiveEffects]) + } + // The builds every route was first measured on. + "++Ares-Core+release-13.01" + | "++Ares-Core+release-13.02" + | "++Ares-Core+release-13.04" + | "++Ares-Core+release-13.05" + | "++Ares-Core+release-13.06" => Self::ALL, + _ => Self::NONE, + } + } + + /// Whether `route` may expand its parent on this branch. + pub(super) fn admits(self, route: MeasuredArrayRoute) -> bool { + self.0 & route.bit() != 0 + } +} + +#[cfg(test)] +mod tests { + use super::MeasuredArrayRoute as Route; + use super::MeasuredArrayRoutes; + use Route::{ + ActiveBlinds, AllPlayersObfuscatedPlayerInformation as PlayerInfo, KillData, + NetworkedProjectilePath as ProjectilePath, RequestedIgnoreActors, SelectedV2, + ServerActiveEffects, TrackedRewards, + }; + + const ALL: &[Route] = &[ + PlayerInfo, + TrackedRewards, + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ActiveBlinds, + ProjectilePath, + ]; + /// 11.06: the path is unobserved and SelectedV2's layout differs. + const B1106: &[Route] = &[KillData, ServerActiveEffects, RequestedIgnoreActors]; + /// 11.07-11.08: SelectedV2's nested attachment handle moved. + const B1107: &[Route] = &[ + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]; + /// 11.09-12.03: the OwnerExclusivePlayerInfo handles are shifted by +3. + const B1109: &[Route] = &[ + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]; + /// 12.04-12.09: everything but ActiveBlinds. + const B1204: &[Route] = &[ + PlayerInfo, + TrackedRewards, + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ProjectilePath, + ]; + /// 12.06-12.07: no projectile path in any sample. + const B1206: &[Route] = &[ + PlayerInfo, + TrackedRewards, + SelectedV2, + KillData, + ServerActiveEffects, + RequestedIgnoreActors, + ]; + /// 12.10, 12.11 and 13.00: the only four routes their one fixture holds. + const FIXTURE: &[Route] = &[PlayerInfo, TrackedRewards, SelectedV2, ServerActiveEffects]; + + /// Every supported branch and the routes it admits, in the order of + /// `vrf_transform::ALL_VERSIONS`. A change to the gate is a change to this + /// table, made together with the measurement that justifies it. + const PINNED: &[(&str, &[Route])] = &[ + ("++Ares-Core+release-11.06", B1106), + ("++Ares-Core+release-11.07", B1107), + ("++Ares-Core+release-11.08", B1107), + ("++Ares-Core+release-11.09", B1109), + ("++Ares-Core+release-11.10", B1109), + ("++Ares-Core+release-11.11", B1109), + ("++Ares-Core+release-12.00", B1109), + ("++Ares-Core+release-12.01", B1109), + ("++Ares-Core+release-12.02", B1109), + ("++Ares-Core+release-12.03", B1109), + ("++Ares-Core+release-12.04", B1204), + ("++Ares-Core+release-12.05", B1204), + ("++Ares-Core+release-12.06", B1206), + ("++Ares-Core+release-12.07", B1206), + ("++Ares-Core+release-12.08", B1204), + ("++Ares-Core+release-12.09", B1204), + ("++Ares-Core+release-12.10", FIXTURE), + ("++Ares-Core+release-12.11", FIXTURE), + ("++Ares-Core+release-13.00", FIXTURE), + ("++Ares-Core+release-13.01", ALL), + ("++Ares-Core+release-13.02", ALL), + ("++Ares-Core+release-13.04", ALL), + ("++Ares-Core+release-13.05", ALL), + ("++Ares-Core+release-13.06", ALL), + ]; + + #[test] + fn routes_are_pinned_for_every_supported_branch() { + // One row per supported branch: a newly supported build must be + // placed in the table deliberately, even if it admits nothing. + let supported: Vec<&str> = vrf_transform::ALL_VERSIONS + .iter() + .map(|version| version.branch()) + .collect(); + let pinned: Vec<&str> = PINNED.iter().map(|(branch, _)| *branch).collect(); + assert_eq!(pinned, supported); + + for (branch, routes) in PINNED { + let admitted = MeasuredArrayRoutes::for_branch(branch); + for route in Route::ALL { + assert_eq!( + admitted.admits(route), + routes.contains(&route), + "{branch}: {route:?}" + ); + } + } + } + + #[test] + fn unknown_branches_admit_no_route() { + for branch in [ + "", + "13.05", + "++Ares-Core+release-13.07", + "++Ares-Core+release-12.12", + "++Ares-Core+release-13.05 ", + ] { + let admitted = MeasuredArrayRoutes::for_branch(branch); + assert_eq!(admitted, MeasuredArrayRoutes::NONE, "{branch:?}"); + for route in Route::ALL { + assert!(!admitted.admits(route), "{branch:?}: {route:?}"); + } + } + } +} diff --git a/native/replay/src/vendor/sink/mod.rs b/native/replay/src/vendor/sink/mod.rs new file mode 100644 index 000000000..557667ca0 --- /dev/null +++ b/native/replay/src/vendor/sink/mod.rs @@ -0,0 +1,718 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/mod.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! Sink implementation connecting `vrf-net` events to `vrf-export` records. +//! +//! # Design: no skipping +//! +//! Every field and RPC is emitted, even when its group path or field name does +//! not resolve: then `group_path = ""` and `field_name = None`. +//! A block an unresolved ClassNetCache function table leaves unsplittable is +//! emitted whole as one marked preservation row, never as fabricated fields, so +//! the Parquet output stays a **lossless** representation of the stream. +//! +//! `vrfkit validate` runs this whole path and writes no file, so it times the +//! sink alone: docs/PERFORMANCE_NOTES.md#what-the-whole-sink-costs. + +mod blobs; +mod failure_stats; +mod intern; +mod measured_routes; +mod paths; +mod rpc; +mod stream; + +pub use failure_stats::{FailureAggregate, MAX_FAILURE_CELLS}; + +use std::sync::Arc; + +use smallvec::SmallVec; +use vrf_decode::{ + ArrayDecodeStats, GroupHashState, OVERLAY_HANDLE_TABLE, OVERLAY_TABLE, OverlayStats, + OverlayTable, group_hash_state, +}; +use vrf_export::{ + ActorRecord, CheckpointBlockRecord, CheckpointIdentity, FieldRecord, MovementRecord, + PartialRecord, +}; +use vrf_net::net_guid::GuidPathSink; +use vrf_net::types::NetworkGuid; +use vrf_schema::{FxHashMap, NetGuidCache}; + +use intern::NameInterner; +use measured_routes::{MeasuredArrayRoute, MeasuredArrayRoutes}; +use paths::{BlockPathMemo, ChannelArchetype}; +use rpc::RpcParamGroupMemo; + +static TABLE: OverlayTable = OverlayTable::with_handles(&OVERLAY_TABLE, &OVERLAY_HANDLE_TABLE); + +/// How many stream-failure lines to retain. See [`ChannelState::stream_failures`]. +const MAX_STREAM_FAILURE_RECORDS: usize = 32; + +/// One BombPlayerState actor's identity for the manifest `players` array: +/// `Subject`, the account UUID, and `SpawnedCharacter`, the character actor +/// NetGUID (equal to `movement.character_net_guid`). Together they join every +/// actor-keyed table to a stable account -- what `playerLoadouts`' +/// `characterId` cannot do when two players pick the same agent. +#[derive(Debug, Clone, Default)] +pub struct PlayerIdentity { + pub subject: Option, + /// Every non-zero `SpawnedCharacter`, once each, ordered by its last write: + /// the last is the current body, earlier ones pawns a reconnect replaced. + pub character_net_guids: Vec, +} + +/// State that must outlive a packet. `ExportSink` is rebuilt for every packet +/// (it borrows the `NetGuidCache` mutably), so the channel archetypes, the two +/// memos and the name pool live here, or they would never warm up. +#[derive(Debug, Clone, Default)] +pub struct ChannelState { + /// channel_index -> archetype, stamped with its actor ([`ChannelArchetype`]). + archetypes: FxHashMap, + rpc_param_groups: RpcParamGroupMemo, + block_paths: BlockPathMemo, + names: NameInterner, + /// Bumped when the archetype map changes: one of [`BlockPathMemo`]'s three + /// stamps (see [`paths`]). + resolution_generation: u64, + /// One line per block whose inner stream did not walk, capped at + /// [`MAX_STREAM_FAILURE_RECORDS`]: a wrong transform fails nearly every + /// block and the first few dozen say it all; the population is in `failures`. + stream_failures: Vec, + /// Opt-in for `diag`; `None` elsewhere, so export pays no sampling cost. + failures: Option, + /// PlayerState actor NetGUID -> [`PlayerIdentity`], filled by `on_field`. + players: FxHashMap, +} + +impl ChannelState { + /// Record one stream failure, up to the cap. + pub fn push_stream_failure(&mut self, line: String) { + if self.stream_failures.len() < MAX_STREAM_FAILURE_RECORDS { + self.stream_failures.push(line); + } + } + + #[must_use] + pub fn stream_failures(&self) -> &[String] { + &self.stream_failures + } + + /// Enable bounded failure aggregation for a diagnostic pass. + pub fn enable_failure_aggregate(&mut self, retain_payloads: bool) { + self.failures = Some(FailureAggregate::new(retain_payloads)); + } + + /// Take the failure aggregate out, leaving it empty: the checkpoint pass + /// drains each chunk's channel state into the caller's totals. + #[must_use] + pub fn take_failure_aggregate(&mut self) -> FailureAggregate { + self.failures.take().unwrap_or_default() + } + + /// Captured player identities, for the manifest `players` array. + // (vendor: `export` feature gate removed) + #[must_use] + pub fn players(&self) -> &FxHashMap { + &self.players + } + + /// Declare that the archetype map changed (the cache's GUID maps have + /// `NetGuidCache::guid_generation`): a resolution input no stamp covers is + /// silent byte movement, not a test failure. + fn note_resolution_input_changed(&mut self) { + self.resolution_generation = self.resolution_generation.wrapping_add(1); + } +} + +/// Everything one pass's sink counted: `Pass::walk` lends the pass's totals +/// to each packet's sink, so they accumulate in place. +#[derive(Debug, Clone, Default)] +pub struct ExportStats { + /// Every row `push_field` wrote: the `fields.parquet` (or + /// `checkpoint_fields.parquet`) row count, not NetStats' `fields`. + pub fields_emitted: u64, + /// The sink's own count of four events vrf-net counts beside the same + /// callbacks, so each must equal its `NetStats` twin: a difference is a + /// missing or extra `+= 1` here, never framing. The manifest prefixes them + /// `sink_`; `tools/verify_build_corpus.py` fails a replay whose pair differs. + pub rpcs_emitted: u64, + pub actor_opens: u64, + pub actor_closes: u64, + pub content_blocks: u64, + /// Its `error_report` holds the pass's decode errors. + pub overlay: OverlayStats, + pub array: ArrayDecodeStats, + /// Exact 24-bit `TrackedRewards` windows with the measured opaque zero + /// byte. They preserve their parent raw row and emit no child rows. + pub tracked_rewards_opaque_empty_variants: u64, + /// Empty ActiveBlinds deltas whose trailing zero IntPacked was spared + /// (`active_blind_array_bits`); the parent row keeps the byte, and no other + /// number moves if a build makes such trailers common. + pub active_blinds_empty_trailers: u64, + /// EffectContainer RPC parameters turned into a `value_str` JSON array: + /// the decoder's only success signal there, as the overlay buckets are + /// filled first. Failures land in `overlay.decoded_err`; the + /// `ServerActiveEffects` members it types are array leaves, not counted here. + pub effect_blobs_decoded: u64, + + /// Struct-blob (`RoundResults`, `TeamEconomy`, `RoundInfos`) parent rows + /// whose dedicated decoder produced elements. + pub struct_blobs_decoded: u64, + + /// Item NetGUID rows from the `MultiItemSlot.MultiContents` decoder, one per + /// item reference. The parent stays `Raw` (`not_in_table`), so as with + /// [`Self::effect_blobs_decoded`] this is the decoder's only signal. + pub multi_contents_items_emitted: u64, + + /// RPC rows from the fc=34 ClassNetCache decoder for + /// `AbilitiesAndBuffsComponent` -- extra rows beside an unresolved + /// payload's preservation row, and verified RepLayout-tail bodies. The only + /// signal that decoder produced RPC structure. + pub cnc_rpcs_emitted: u64, + + /// Unresolved `AbilitiesAndBuffsComponent` payloads offered to that + /// brute-force walk: the denominator [`Self::cnc_rpcs_emitted`] does not + /// have, since that counter also counts RepLayout-tail decodes. + pub cnc_bruteforce_payloads_attempted: u64, + + /// Of those, payloads the fc=34 walk did not fit: only the preservation row, + /// which keeps every bit. 34 is empirical, so if an update breaks the walk + /// `CNC RPC rows` shrinks and this says why. + pub cnc_bruteforce_payloads_unwalked: u64, + + /// Post-RepLayout ClassNetCache tails decoded under verified component + /// provenance and strict one-RPC framing. + pub rep_layout_cnc_tails_decoded: u64, + + /// Post-RepLayout tails retained whole because their provenance or framing + /// was not sufficient for the verified decoder. + pub rep_layout_cnc_tails_preserved: u64, + + /// Struct-blob decodes that returned an error: additive, so no row or bit is + /// lost, but a moved `RoundResults` handle would otherwise export a clean + /// run with no match score. + pub struct_blobs_failed: u64, + + /// The first failure verbatim, never overwritten: it names the member and + /// handle a build moved. + pub struct_blob_first_error: Option, + + /// Movement-decode problems: soft errors counted per occurrence + /// (`RpcDecodeResult.error_count`) plus hard `Err`s. Without it a changed + /// section format shortens `movement.parquet` with every counter clean. + pub movement_rpc_errors: u64, + + /// The first movement-decode problem verbatim, for the summary to name. + pub movement_first_error: Option, + + /// Sections in a `movementBitCount`-sized window that stopped with bits + /// unread, and those bits (`vrf_movement::RpcDecodeResult::sized_section_tails`). + /// A soft tally: the batch still counts as clean, so no Parquet row moves. + pub movement_sized_section_tails: u64, + pub movement_sized_section_tail_bits: u64, + /// The same for windows that ran to the end of the component stream, where + /// what follows may be other data: kept apart, never summed with the above. + pub movement_open_section_tails: u64, + pub movement_open_section_tail_bits: u64, + /// Byte-wrapped movement streams and the bits after their envelopes, + /// which nothing reads (`vrf_movement::RpcDecodeResult::envelope_trailer_bits`): + /// 24 per stream on every measured replay. A soft tally like the tails. + pub movement_envelope_trailers: u64, + pub movement_envelope_trailer_bits: u64, + + /// RPC payloads whose parameter loop broke on a malformed read before the + /// zero-handle terminator. The walk keeps its rows and reports success, so + /// this is the only signal of an abandoned walk. Zero on valid replays. + pub truncated_rpcs: u64, + + /// RPC parameter walks begun (a function name resolved): the work whose + /// zero `truncated_rpcs` is evidence of. + pub rpc_param_walks: u64, + + /// Bits after an RPC's zero-handle terminator beyond the one alignment bit + /// `FunctionParameters` allows. Counted, not rejected, and not lost: the + /// payload also gets a whole-payload row, so every counted bit is in + /// `raw_bits`. Nonzero on the main pass of 21 of 24 corpus builds, all from + /// `ActiveGameplayEffects` on `AresAbilitySystemComponent_ClassNetCache` + /// (custom-delta data past the zero handle; see `ActiveGameplayEffects` in + /// docs/DATA.md); a suffix on any other handle would be new. + pub rpc_suffix_bits_dropped: u64, + + /// Flattened array leaves whose resolved type failed to decode; the raw leaf + /// rows are still emitted, this counts the typed values lost. + pub array_leaf_decode_errors: u64, + + /// Typed world-location children emitted from the guarded map-click array. + pub targeting_world_locations_decoded: u64, + + /// Child rows each measured array route emitted ([`Self::route_children`]): + /// a route whose layout moved refuses every leaf while the others keep the + /// array totals up. + pub route_children_player_information: u64, + pub route_children_tracked_rewards: u64, + pub route_children_selected_v2: u64, + pub route_children_kill_data: u64, + pub route_children_server_active_effects: u64, + pub route_children_requested_ignore_actors: u64, + pub route_children_active_blinds: u64, + pub route_children_projectile_path: u64, +} + +/// `counters` and `counters_mut` over one list per struct. Each destructure +/// has no `..`, so a new counter does not compile until it is listed, and each +/// name is its field's (the decoder structs' prefixed). +macro_rules! export_counters { + ($($prefix:literal $ty:ident $(.$place:ident)? { $($field:ident),* } except { $($skip:ident),* })*) => { + impl ExportStats { + /// Every counter by its published name; the two first-error strings + /// and the overlay's error report are not counters. + pub(crate) fn counters(&self) -> Vec<(&'static str, u64)> { + let mut all = Vec::new(); + $({ + let $ty { $(ref $field,)* $($skip: _,)* } = (*self)$(.$place)?; + all.extend([$((concat!($prefix, stringify!($field)), *$field)),*]); + })* + all + } + + /// [`Self::counters`], writable. + #[cfg(test)] + pub(crate) fn counters_mut(&mut self) -> Vec<(&'static str, &mut u64)> { + let mut all = Vec::new(); + $({ + let $ty { $(ref mut $field,)* $($skip: _,)* } = (*self)$(.$place)?; + all.extend([$((concat!($prefix, stringify!($field)), $field)),*]); + })* + all + } + } + }; +} + +export_counters! { + "" ExportStats { + fields_emitted, rpcs_emitted, actor_opens, actor_closes, content_blocks, + tracked_rewards_opaque_empty_variants, active_blinds_empty_trailers, effect_blobs_decoded, + struct_blobs_decoded, multi_contents_items_emitted, cnc_rpcs_emitted, + cnc_bruteforce_payloads_attempted, cnc_bruteforce_payloads_unwalked, + rep_layout_cnc_tails_decoded, rep_layout_cnc_tails_preserved, struct_blobs_failed, + movement_rpc_errors, movement_sized_section_tails, movement_sized_section_tail_bits, + movement_open_section_tails, movement_open_section_tail_bits, movement_envelope_trailers, + movement_envelope_trailer_bits, truncated_rpcs, rpc_param_walks, rpc_suffix_bits_dropped, + array_leaf_decode_errors, targeting_world_locations_decoded, + route_children_player_information, route_children_tracked_rewards, + route_children_selected_v2, route_children_kill_data, route_children_server_active_effects, + route_children_requested_ignore_actors, route_children_active_blinds, + route_children_projectile_path + } except { overlay, array, struct_blob_first_error, movement_first_error } + "overlay_" OverlayStats.overlay { + decoded_ok, decoded_err, raw_or_skip, not_in_table, no_field_name, handle_conflicts_refused + } except { error_report } + "array_" ArrayDecodeStats.array { + elements_decoded, fields_emitted, truncations, errors, unconsumed_nested_bits, + unconsumed_root_bits, implicit_terminations + } except {} +} + +impl ExportStats { + /// `route`'s child-row counter; no wildcard, so a new route does not + /// compile until it has one. + fn route_children(&mut self, route: MeasuredArrayRoute) -> &mut u64 { + match route { + MeasuredArrayRoute::AllPlayersObfuscatedPlayerInformation => { + &mut self.route_children_player_information + } + MeasuredArrayRoute::TrackedRewards => &mut self.route_children_tracked_rewards, + MeasuredArrayRoute::SelectedV2 => &mut self.route_children_selected_v2, + MeasuredArrayRoute::KillData => &mut self.route_children_kill_data, + MeasuredArrayRoute::ServerActiveEffects => { + &mut self.route_children_server_active_effects + } + MeasuredArrayRoute::RequestedIgnoreActors => { + &mut self.route_children_requested_ignore_actors + } + MeasuredArrayRoute::ActiveBlinds => &mut self.route_children_active_blinds, + MeasuredArrayRoute::NetworkedProjectilePath => &mut self.route_children_projectile_path, + } + } + + /// Record a movement-RPC decode outcome: soft per-update errors and hard + /// `Err`s both count, and the first is kept verbatim for the summary. + pub fn record_movement_decode( + &mut self, + result: Result<&vrf_movement::RpcDecodeResult, &vrf_movement::MovementError>, + ) { + match result { + Ok(r) => { + // No `..`: a counter added to the decoder's result must be + // read here, or it would reach nothing. + let vrf_movement::RpcDecodeResult { + total_moves: _, + update_count: _, + error_count, + sized_section_tails, + sized_section_tail_bits, + open_section_tails, + open_section_tail_bits, + envelope_trailer_streams, + envelope_trailer_bits, + } = *r; + self.movement_sized_section_tails += u64::from(sized_section_tails); + self.movement_sized_section_tail_bits += sized_section_tail_bits; + self.movement_open_section_tails += u64::from(open_section_tails); + self.movement_open_section_tail_bits += open_section_tail_bits; + self.movement_envelope_trailers += u64::from(envelope_trailer_streams); + self.movement_envelope_trailer_bits += envelope_trailer_bits; + if error_count > 0 { + self.movement_rpc_errors = self + .movement_rpc_errors + .saturating_add(u64::from(error_count)); + self.movement_first_error.get_or_insert(format!( + "{error_count} movement decode error(s) in one RPC batch" + )); + } + } + Err(e) => { + self.movement_rpc_errors = self.movement_rpc_errors.saturating_add(1); + self.movement_first_error + .get_or_insert_with(|| e.to_string()); + } + } + } +} + +#[cfg(test)] +mod stats_tests { + use super::ExportStats; + use vrf_movement::{MovementError, RpcDecodeResult}; + + /// A clean-framed batch with `error_count` soft errors and the six tallies + /// (sized tails, bits, open tails, bits, envelope trailers, bits). + fn batch(error_count: u32, t: [u32; 6]) -> Result { + Ok(RpcDecodeResult { + error_count, + sized_section_tails: t[0], + sized_section_tail_bits: t[1].into(), + open_section_tails: t[2], + open_section_tail_bits: t[3].into(), + envelope_trailer_streams: t[4], + envelope_trailer_bits: t[5].into(), + ..Default::default() + }) + } + + /// Each tail and trailer tally sums into its own counter from every `Ok` + /// decode, soft errors or not, and none is a movement error (which would + /// keep the batch as a raw row); an `Err` carries no tally. + #[test] + fn tallies_sum_field_by_field_and_are_not_errors() { + let mut s = ExportStats::default(); + s.record_movement_decode(batch(0, [1, 2, 3, 4, 5, 6]).as_ref()); + s.record_movement_decode(batch(1, [10, 20, 30, 40, 50, 60]).as_ref()); + s.record_movement_decode(Err(MovementError::ErrorSentinel).as_ref()); + let tallies = [ + s.movement_sized_section_tails, + s.movement_sized_section_tail_bits, + s.movement_open_section_tails, + s.movement_open_section_tail_bits, + s.movement_envelope_trailers, + s.movement_envelope_trailer_bits, + ]; + assert_eq!(tallies, [11, 22, 33, 44, 55, 66]); + assert_eq!(s.movement_rpc_errors, 2, "the soft error and the Err"); + } + + /// A clean decode records nothing; soft errors count per occurrence and + /// keep the first text; a later hard error adds one without overwriting + /// it; a first hard error records its Display. + #[test] + fn movement_errors_count_every_occurrence_and_keep_the_first() { + let mut s = ExportStats::default(); + s.record_movement_decode(batch(0, [0; 6]).as_ref()); + assert_eq!(s.movement_rpc_errors, 0); + assert!(s.movement_first_error.is_none()); + s.record_movement_decode(batch(3, [0; 6]).as_ref()); + s.record_movement_decode(Err(MovementError::InvalidMagic(0x00)).as_ref()); + assert_eq!(s.movement_rpc_errors, 4); + assert_eq!( + s.movement_first_error.as_deref(), + Some("3 movement decode error(s) in one RPC batch") + ); + + let mut hard = ExportStats::default(); + hard.record_movement_decode(Err(MovementError::ErrorSentinel).as_ref()); + assert_eq!(hard.movement_rpc_errors, 1); + let msg = hard.movement_first_error.expect("first error recorded"); + assert!(msg.contains("sentinel"), "got: {msg}"); + } + + /// Two struct blobs that fail differently: both are counted and the first + /// failure's text stays (`Pass::walk` lends one `ExportStats` to every + /// packet, so this holds across packets too). + #[test] + fn a_later_struct_blob_failure_keeps_the_first_error() { + use std::sync::Arc; + use vrf_bitio::BitReader; + use vrf_net::field::FieldSink; + let group = "/Game/GameModes/Bomb/BombGameState.BombGameState_C"; + let mut rig = super::test_fixtures::Rig::default(); + let export_group = vrf_schema::NetFieldExportGroup::new(group.into(), 7, 8); + rig.cache.add_export_group(export_group).unwrap(); + let field = vrf_schema::NetFieldExport { + handle: 1, + compatible_checksum: 0, + name: "RoundResults".into(), + }; + assert!(rig.cache.set_field_on_group(7, field)); + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from(group)); + for bits in [3u32, 40] { + sink.on_field( + 1, + bits, + BitReader::with_bit_len(&[0xFF; 8], u64::from(bits)).unwrap(), + ); + } + assert_eq!(sink.stats.struct_blobs_failed, 2); + let first = sink.stats.struct_blob_first_error.as_deref().unwrap(); + assert!( + first.contains("needed 8 bit(s) at position 0 of 3"), + "{first}" + ); + } +} + +/// The record buffers a sink fills for one packet, lent so their capacity +/// survives it (docs/PERFORMANCE_NOTES.md#recordbuffers-are-lent-not-owned). +/// [`ExportSink::new`] clears them, so a caller that never drains them does not +/// accumulate the whole replay. +#[derive(Debug, Default)] +pub struct RecordBuffers { + pub fields: Vec, + pub movement: Vec, + pub actors: Vec, + pub partials: Vec, + pub checkpoint_blocks: Vec, +} + +/// The export sink: turns `vrf-net` events into records for the Parquet +/// writers. It borrows the `NetGuidCache` mutably because `vrf-net` calls +/// `GuidPathSink::register_path` mid-packet, for package-map export bunches +/// that declare GUID -> path mappings inline. +pub struct ExportSink<'a> { + pub cache: &'a mut NetGuidCache, + channel_state: &'a mut ChannelState, + pub time_ms: u32, + pub packet_id: u32, + records: &'a mut RecordBuffers, + pub stats: ExportStats, + /// The checksum-gated structured-array routes this replay's branch admits. + /// See [`measured_routes`]; empty until `enable_measured_array_routes`. + measured_array_routes: MeasuredArrayRoutes, + checkpoint_block_scope: Option<(CheckpointIdentity, u64, u32)>, + + // -- per-content-block context (set by on_content_block) ---------------- + current_channel: u32, + current_actor_guid: u32, + /// Subobject GUID of the block being walked; `None` for actor blocks. + current_object_guid: Option, + /// True only when the replay's direct, pre-remap object path is exactly the + /// measured component that carries the chained fc=34 ClassNetCache stream. + current_is_abilities_and_buffs: bool, + /// Interned: a block's rows share one allocation of the path ([`intern`]). + current_group_path: Arc, + /// The half-finished overlay key hash for [`current_group_path`](Self::current_group_path), + /// so each of ~2M probes per replay hashes only the field name. A stale + /// value turns hits into misses (`raw_bits` only), never a wrong type. + current_group_hash: GroupHashState, + current_group_resolution_source: &'static str, + current_function_count_source: &'static str, + current_resolution_memo_hit: bool, +} + +impl<'a> ExportSink<'a> { + /// Build a sink for one packet; clears `records` (see [`RecordBuffers`]). + pub fn new( + cache: &'a mut NetGuidCache, + channel_state: &'a mut ChannelState, + records: &'a mut RecordBuffers, + ) -> Self { + records.fields.clear(); + records.movement.clear(); + records.actors.clear(); + records.partials.clear(); + records.checkpoint_blocks.clear(); + let current_group_path = empty_group_path(); + let current_group_hash = group_hash_state(¤t_group_path); + Self { + cache, + channel_state, + time_ms: 0, + packet_id: 0, + records, + stats: ExportStats::default(), + measured_array_routes: MeasuredArrayRoutes::NONE, + checkpoint_block_scope: None, + current_channel: 0, + current_actor_guid: 0, + current_object_guid: None, + current_is_abilities_and_buffs: false, + current_group_path, + current_group_hash, + current_group_resolution_source: "unset", + current_function_count_source: "unset", + current_resolution_memo_hit: false, + } + } + + /// Admit the structured-array routes measured for `branch` ([`measured_routes`]). + pub(super) fn enable_measured_array_routes(&mut self, branch: &str) { + self.measured_array_routes = MeasuredArrayRoutes::for_branch(branch); + } + + /// Whether `route` may expand its parent in this replay. + fn admits(&self, route: MeasuredArrayRoute) -> bool { + self.measured_array_routes.admits(route) + } + + // (vendor: `export` feature gate removed) + pub(super) fn enable_checkpoint_block_context( + &mut self, + checkpoint: CheckpointIdentity, + field_row_offset: u64, + block_index_offset: u32, + ) { + self.checkpoint_block_scope = Some((checkpoint, field_row_offset, block_index_offset)); + } + + /// Set `current_group_path` and refresh its cached hash together; every + /// assignment goes through here, or + /// [`current_group_hash`](Self::current_group_hash) goes stale. + fn set_current_group_path(&mut self, path: Arc) { + self.current_group_hash = group_hash_state(&path); + self.current_group_path = path; + } + + /// Push one field row, stamped with the current block context. Every + /// `FieldRecord` this crate produces is built and counted here, so no call + /// site can get the six block-context columns or `fields_emitted` wrong. + fn push_field(&mut self, row: FieldValues) { + self.stats.fields_emitted += 1; + self.records.fields.push(FieldRecord { + time_ms: self.time_ms, + packet_id: self.packet_id, + channel_index: self.current_channel, + actor_net_guid: self.current_actor_guid, + object_net_guid: self.current_object_guid, + // A refcount bump, not a copy: the column the interning exists for. + group_path: Arc::clone(&self.current_group_path), + handle: row.handle, + field_name: row.field_name, + compatible_checksum: row.compatible_checksum, + bit_count: row.bit_count, + raw_bits: row.raw_bits, + value_i64: row.value_i64, + value_f64: row.value_f64, + value_bool: row.value_bool, + value_str: row.value_str, + }); + if self.checkpoint_block_scope.is_some() { + if let Some(block) = self.records.checkpoint_blocks.last_mut() { + block.field_row_count += 1; + } + } + } +} + +/// A field row minus its block context; see [`ExportSink::push_field`]. +#[derive(Debug, Default)] +struct FieldValues { + handle: u32, + field_name: Option>, + /// The replay's declared checksum for this handle; `None` (the default) for + /// rows addressed inside a payload, such as array leaves and struct blobs. + compatible_checksum: Option, + bit_count: u32, + raw_bits: Option>, + value_i64: Option, + value_f64: Option, + value_bool: Option, + value_str: Option, +} + +/// The group path a sink starts with: one process-wide empty `Arc`, not an +/// allocation per packet. Observable only if a field arrived before its +/// content block, which the framer never does. +fn empty_group_path() -> Arc { + use std::sync::OnceLock; + static EMPTY: OnceLock> = OnceLock::new(); + Arc::clone(EMPTY.get_or_init(|| Arc::from(""))) +} + +impl GuidPathSink for ExportSink<'_> { + /// Record a GUID -> path mapping the wire declared inline. + fn register_path(&mut self, guid: u32, path: &str, outer_guid: NetworkGuid) { + self.cache.set_net_guid_path(guid, path, Some(outer_guid)); + } + + fn path_for_guid(&self, guid: u32) -> Option<&str> { + self.cache.get_path_by_guid(guid) + } +} + +/// Builders shared by the sink's test modules. +#[cfg(test)] +mod test_fixtures { + use vrf_net::content::ContentBlockHeader; + use vrf_net::pipeline::ActorChannelState; + use vrf_net::types::NetworkGuid; + use vrf_schema::NetGuidCache; + + use super::{ChannelState, ExportSink, RecordBuffers}; + + /// What a sink borrows, owned by one test. + #[derive(Default)] + pub(super) struct Rig { + pub(super) cache: NetGuidCache, + pub(super) state: ChannelState, + pub(super) records: RecordBuffers, + } + + impl Rig { + pub(super) fn sink(&mut self) -> ExportSink<'_> { + ExportSink::new(&mut self.cache, &mut self.state, &mut self.records) + } + } + + pub(super) fn actor_block(has_rep_layout: bool) -> ContentBlockHeader { + ContentBlockHeader { + has_rep_layout, + is_actor: true, + ..ContentBlockHeader::default() + } + } + + pub(super) fn subobject_block(guid: u32, has_rep_layout: bool) -> ContentBlockHeader { + ContentBlockHeader { + has_rep_layout, + object_net_guid: NetworkGuid(guid), + ..ContentBlockHeader::default() + } + } + + /// An `ActorChannelState` for one channel open. + pub(super) fn channel_open( + channel_index: u32, + actor: u32, + archetype: u32, + ) -> ActorChannelState { + ActorChannelState { + channel_index, + is_open: true, + actor_net_guid: NetworkGuid(actor), + archetype_net_guid: NetworkGuid(archetype), + ..ActorChannelState::default() + } + } +} diff --git a/native/replay/src/vendor/sink/paths.rs b/native/replay/src/vendor/sink/paths.rs new file mode 100644 index 000000000..78c5e6bbe --- /dev/null +++ b/native/replay/src/vendor/sink/paths.rs @@ -0,0 +1,813 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/paths.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! Content-block group-path resolution. +//! +//! Every content block is attributed to a declared export group before any +//! field in it can be named (`resolve_actor_group_path`, +//! `resolve_subobject_group_path`, `NetGuidCache::unique_leaf_match`). +//! +//! # The memo +//! +//! [`BlockPathMemo`] caches the resolution, a pure function of the [`BlockKey`] +//! and of three inputs with independent stamps: the declared group paths +//! (`NetGuidCache::schema_generation`); the GUID -> path and GUID -> outer maps +//! (`NetGuidCache::guid_generation`, which also sees the frame-level +//! `vrf_frame::read_export_data` writes that bypass `register_path`); and the +//! channel -> (actor, archetype) map (`ChannelState::resolution_generation`, +//! moved only by `on_actor_open`/`on_actor_close`). Any stamp moving discards +//! the whole memo, so a hit is indistinguishable from a recomputation; some +//! stamp moves every ~92 blocks, so the unbounded table never grows far. The +//! value carries the function count because `resolve_function_count` can +//! replace the path. Hit rate: docs/PERFORMANCE_NOTES.md#group-path-resolution-memo. + +use std::sync::Arc; + +use vrf_net::content::ContentBlockHeader; +use vrf_net::types::NetworkGuid; +use vrf_schema::{FxHashMap, NetFieldExportGroup, find_class_net_cache_key, find_replay_path_key}; + +use super::{ChannelState, ExportSink}; + +/// Stably named subobject leaves -> the class path they resolve to, tagged with +/// the block kind each applies to: the fallback when no `class_net_guid` names +/// the class. +/// +/// The first four pairs are the ClassNetCache effect entries. The rest are +/// RepLayout-only: VALORANT replicates components under bare instance names but +/// declares their layouts under the class (native, or the `/Game/` Blueprint +/// class the replay declares), and the AbilitySystem `_ClassNetCache` group is +/// declared with an incomplete function table, so its RPC stream stays +/// unresolved and is brute-forced at fc=34; every remapped component's RPC rows +/// stay bare. +/// +/// The pairs are read from the shipped game by `tools/extract_component_classes` +/// (docs/DATA.md, "Reading component classes out of the game", has the +/// procedure, numbers and rejects); `tools/check_component_remaps.py` watches +/// for the symptoms of a rename, which nothing here can detect. +#[rustfmt::skip] +const KNOWN_SUBOBJECT_CLASS_PATHS: &[(&str, &str, GroupKind)] = &[ + ("ReplayEffect", "/Script/ShooterGame.ReplayEffectComponent", GroupKind::ClassNetCache), + ("EffectManager", "/Script/ShooterGame.EffectManagerComponent", GroupKind::ClassNetCache), + ("LocationalEffectManager", "/Script/ShooterGame.LocationalEffectManagerComponent", GroupKind::ClassNetCache), + ("DamageHandlerComponent", "/Script/ShooterGame.DamageableComponent", GroupKind::ClassNetCache), + ("InventoryComponent", "/Script/ShooterGame.AresInventory", GroupKind::RepLayout), + ("AbilitiesAndBuffsComponent", "/Script/ShooterGame.AresAbilitySystemComponent", GroupKind::RepLayout), + ("ZoomStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("SelectBounceStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("StateMachine_Priming", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("TargetingToggle_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("SuppressionRoundsStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("BoostStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("Gun_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("RewindStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("UseAbilityStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("Resume_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("Sprint_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("Slide_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("ProjectileStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("EquipStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("PrimaryTriggerActionStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("EquippableStateMachine_Activate", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("LaserStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("SelfResStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("Ability State Machine (EquippableStateMachine)", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("TimerStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("CloakStateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("SpontaneousEquip_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("EquippableStateMachine_Dart", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("EquippableStateMachine_Attack", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("EquippableStateMachine_PickUpOnCooldown", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + ("SwapCameras_StateMachine", "/Script/ShooterGame.EquippableStateMachineComponent", GroupKind::RepLayout), + // One native class, two instances; handle 2 is `AuthResourceAmount` + // (magazine 0..100, reserve 0..200). + ("MagazineAmmo", "/Script/ShooterGame.AmmoComponent", GroupKind::RepLayout), + ("ReserveAmmo", "/Script/ShooterGame.AmmoComponent", GroupKind::RepLayout), + ("CalloutRegionTracker", "/Script/ShooterGame.CalloutRegionTrackingComponent", GroupKind::RepLayout), + ("VisionComponent", "/Script/ShooterGame.ShooterCharacterVisionComponent", GroupKind::RepLayout), + ("HealthDamageSection", "/Script/ShooterGame.ChildDamageSectionComponent", GroupKind::RepLayout), + ("ShieldDamageSection", "/Script/ShooterGame.ChildDamageSectionComponent", GroupKind::RepLayout), + ("OverhealDamageSection", "/Script/ShooterGame.ChildDamageSectionComponent", GroupKind::RepLayout), + ("PreventDeathDamageSection", "/Script/ShooterGame.AttachedDamageSectionComponent", GroupKind::RepLayout), + ("UsableComponent_EquippableGroundPickup", "/Script/ShooterGame.UsableComponent_EquippableGroundPickups", GroupKind::RepLayout), + ("Usable_PickUp", "/Script/ShooterGame.UsableComponent", GroupKind::RepLayout), + ("StealthComp", "/Script/ShooterGame.SimpleVisualTimelineStealthComp", GroupKind::RepLayout), + ("StealthV1AddedForAISight", "/Script/ShooterGame.StealthComponent", GroupKind::RepLayout), + ("Collision Static Mesh", "/Script/Engine.StaticMeshComponent", GroupKind::RepLayout), + ("PMAimToolingPointsTarget", "/Script/InputTooling.AimToolingPointsTargetComponent", GroupKind::RepLayout), + // Handle 2 `AttachParent` is a packed reference: 16 bits vs the target's 24 + // is the NetGUID's size, not a type disagreement. + ("PMAimToolingTarget", "/Script/InputTooling.AimToolingSkeletalTargetComponent", GroupKind::RepLayout), + ("Comp_Ability_CooldownComponent1", "/Game/Characters/Components/Comp_Ability_CooldownComponent.Comp_Ability_CooldownComponent_C", GroupKind::RepLayout), + ("DamageSection_Vampire_Q_BloodArmor", "/Game/Characters/Vampire/S0/Ability_Q/DamageSection_Vampire_Q_Heal_BloodArmor.DamageSection_Vampire_Q_Heal_BloodArmor_C", GroupKind::RepLayout), + ("ChooseTeleportSpot_StateComponent", "/Game/Characters/States/ChooseMapLocationOnNavMesh_StateComponent.ChooseMapLocationOnNavMesh_StateComponent_C", GroupKind::RepLayout), + // The 13.06 armour section. Beside Phoenix's native `PreventDeathDamageSection`, + // `unique_leaf_match` binds that native parent first (only `bAlive`), so + // handles 3 and 5 stay unnamed there. + ("AttachedDamageSection", "/Game/Gear/BasicArmorAttachedDamageSection.BasicArmorAttachedDamageSection_C", GroupKind::RepLayout), + // GAS attribute sets, runtime subobjects in no cooked asset: wire evidence + // only (every handle declared by the native group, all at 32 bits). + ("AresAttributeSet_1", "/Script/ShooterGame.AresAttributeSet", GroupKind::RepLayout), + ("AresAttributeSet_2", "/Script/ShooterGame.AresAttributeSet", GroupKind::RepLayout), +]; + +/// Everything a block's resolution reads that is not cache state. All six stay +/// in the key though each branch ignores some: dropping a field the resolution +/// does read is silent byte movement, while an over-precise key costs entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct BlockKey { + channel_index: u32, + actor_net_guid: u32, + class_net_guid: u32, + object_net_guid: u32, + is_actor: bool, + has_rep_layout: bool, +} + +/// One block's resolution, as [`ExportSink::resolve_block`] memoises it. +#[derive(Debug, Clone)] +struct Resolution { + path: Arc, + function_count: u32, + group_source: &'static str, + count_source: &'static str, +} + +/// Memo for [`ExportSink::resolve_block`]; see the module docs. +#[derive(Debug, Clone, Default)] +pub(super) struct BlockPathMemo { + /// The (schema, resolution, GUID) generations `entries` is valid for. + stamps: (u64, u64, u64), + entries: FxHashMap, +} + +impl BlockPathMemo { + /// Drop everything if any stamp has moved, then report the entry for `key`. + fn get(&mut self, key: &BlockKey, stamps: (u64, u64, u64)) -> Option { + if self.stamps != stamps { + self.entries.clear(); + self.stamps = stamps; + return None; + } + self.entries.get(key).cloned() + } +} + +impl ExportSink<'_> { + /// Resolve one content block, memoised: set `current_group_path` and return + /// the function count its RPC handles are read against (0 for a RepLayout + /// block or an unresolved group). + pub(super) fn resolve_block( + &mut self, + channel_index: u32, + actor_net_guid: NetworkGuid, + header: &ContentBlockHeader, + ) -> u32 { + let key = BlockKey { + channel_index, + actor_net_guid: actor_net_guid.0, + class_net_guid: header.class_net_guid.0, + object_net_guid: header.object_net_guid.0, + is_actor: header.is_actor, + has_rep_layout: header.has_rep_layout, + }; + let stamps = ( + self.cache.schema_generation(), + self.channel_state.resolution_generation, + self.cache.guid_generation(), + ); + if let Some(hit) = self.channel_state.block_paths.get(&key, stamps) { + self.set_current_group_path(hit.path); + self.current_group_resolution_source = hit.group_source; + self.current_function_count_source = hit.count_source; + self.current_resolution_memo_hit = true; + return hit.function_count; + } + + let (path, group_source) = if header.is_actor { + self.resolve_actor_group_path(channel_index, actor_net_guid.0, header) + } else { + self.resolve_subobject_group_path(header) + }; + let interned = self.channel_state.names.intern(&path); + self.set_current_group_path(interned); + // May replace `current_group_path`, hence before the path is stored. + let (function_count, count_source) = if header.has_rep_layout { + (0, "rep_layout_not_applicable") + } else { + self.resolve_function_count() + }; + let group_source = if count_source == "class_net_cache_instance_name" { + count_source + } else { + group_source + }; + self.current_group_resolution_source = group_source; + self.current_function_count_source = count_source; + self.current_resolution_memo_hit = false; + let resolution = Resolution { + path: Arc::clone(&self.current_group_path), + function_count, + group_source, + count_source, + }; + self.channel_state + .block_paths + .entries + .insert(key, resolution); + function_count + } + + /// Actor blocks: the first group of the wanted kind matched by the combined + /// archetype candidate, the archetype's outer (package) path, the archetype + /// path unless it is a CDO, then the actor GUID's own path. + fn resolve_actor_group_path( + &self, + channel_index: u32, + actor_guid: u32, + header: &ContentBlockHeader, + ) -> (String, &'static str) { + let archetype = + channel_archetype(self.channel_state, channel_index, NetworkGuid(actor_guid)); + let (package_path, archetype_path) = self.archetype_paths(archetype); + let combined = combined_candidate(package_path.as_deref(), archetype_path.as_deref()); + + // A ClassNetCache block accepts only a `_ClassNetCache` group: the key + // `AggroBot_PC.AggroBot_PC_C` would otherwise bind the 14-field + // RepLayout group, not the 4-field ClassNetCache one, and + // ReadSerializedInt would consume the wrong number of bits. + let want = GroupKind::for_block(header); + + if let Some(hit) = self.match_group(combined.as_deref(), want) { + return (hit, "actor_archetype_combined"); + } + if let Some(hit) = self.match_group(package_path.as_deref(), want) { + return (hit, "actor_archetype_outer"); + } + if let Some(arch) = archetype_path.as_deref() { + if !leaf(arch).starts_with("Default__") { + if let Some(hit) = self.match_group(Some(arch), want) { + return (hit, "actor_archetype_path"); + } + } + } + if let Some(actor_path) = self.cache.get_path_by_guid(actor_guid) { + // A static actor arrives as a bare instance name (`AresWorldSettings`). + return self.resolve_named_path( + actor_path, + want, + [ + "actor_guid_path", + "actor_guid_unique_leaf", + "actor_guid_known_remap", + "actor_guid_unresolved_fallback", + ], + ); + } + + // The best candidate even unmatched: the export needs a path, and the + // raw bits still ship. + if let Some(combined) = combined { + (combined, "actor_archetype_combined_unresolved_fallback") + } else if let Some(package_path) = package_path { + (package_path, "actor_archetype_outer_unresolved_fallback") + } else { + (format!(""), "actor_unknown") + } + } + + /// Subobject blocks: the `class_net_guid` path, else the object's outer path + /// as a group, then its own path. + fn resolve_subobject_group_path(&self, header: &ContentBlockHeader) -> (String, &'static str) { + let want = GroupKind::for_block(header); + let (class_guid, object_guid) = (header.class_net_guid.0, header.object_net_guid.0); + + if class_guid != 0 { + if let Some(class_path) = self.cache.get_path_by_guid(class_guid) { + return self.resolve_named_path( + class_path, + want, + [ + "subobject_class_guid_path", + "subobject_class_guid_unique_leaf", + "subobject_class_guid_known_remap", + "subobject_class_guid_unresolved_fallback", + ], + ); + } + } + if object_guid != 0 { + if let Some(obj_path) = self.cache.get_path_by_guid(object_guid) { + let outer = self.cache.get_outer_path(object_guid); + if let Some(hit) = self.match_group(outer, want) { + return (hit, "subobject_object_outer_path"); + } + return self.resolve_named_path( + obj_path, + want, + [ + "subobject_object_guid_path", + "subobject_object_guid_unique_leaf", + "subobject_object_guid_known_remap", + "subobject_object_guid_unresolved_fallback", + ], + ); + } + } + let fallback_guid = if class_guid != 0 { + class_guid + } else { + object_guid + }; + (format!(""), "subobject_unknown") + } + + /// `path` as a declared group of kind `want`, then as a unique leaf (an + /// ambiguous leaf binds nothing), then through the remap table (bare + /// `InventoryComponent` never leaf-matches `AresInventory`), else `path` + /// itself; `labels` name the four outcomes in that order. The kind guard on + /// the leaf match keeps a RepLayout group from a ClassNetCache block. + fn resolve_named_path( + &self, + path: &str, + want: GroupKind, + labels: [&'static str; 4], + ) -> (String, &'static str) { + if let Some(hit) = self.match_group(Some(path), want) { + return (hit, labels[0]); + } + if let Some(group) = self.cache.unique_leaf_match(path) { + if want.accepts(group) { + return (group.path.clone(), labels[1]); + } + } + let known = resolve_known_subobject_class_path(path, want); + if let Some(hit) = self.match_group(known, want) { + return (hit, labels[2]); + } + (path.to_owned(), labels[3]) + } + + /// The canonical path of the first declared group of kind `want` that a + /// lookup key of `candidate` finds. One function, so no caller can pick the + /// wrong key generator or skip the `_ClassNetCache` guard. + fn match_group(&self, candidate: Option<&str>, want: GroupKind) -> Option { + let candidate = candidate?; + want.find(candidate, |key| { + let group = self.cache.get_group_by_path(key)?; + want.accepts(group).then(|| group.path.clone()) + }) + } + + /// `(outer path, path)` of a valid archetype GUID; either is `None` while + /// the cache lacks it. Owned: borrowing them measured no gain. + pub(super) fn archetype_paths( + &self, + archetype: Option, + ) -> (Option, Option) { + let Some(guid) = archetype.filter(|guid| guid.is_valid()) else { + return (None, None); + }; + ( + self.cache.get_outer_path(guid.0).map(str::to_owned), + self.cache.get_path_by_guid(guid.0).map(str::to_owned), + ) + } + + /// A ClassNetCache block's function count: its group's declared slot count, + /// the bound its handle is read as a SerializedInt against. 0 when no group + /// resolves: the payload is then preserved raw and counted as a stream + /// failure, never dropped. The group resolution already probed every + /// class and archetype key for a `_ClassNetCache` group, so only the + /// current path and a bare instance name are left to try. + fn resolve_function_count(&mut self) -> (u32, &'static str) { + if let Some(group) = self.cache.get_group_by_path(&self.current_group_path) { + if is_class_net_cache(group) { + return (group.len(), "current_resolved_group"); + } + } + // A bare instance name -- static actors (`BombDestination_A`), stably + // named subobjects (`ForceModuleManager`) -- has no archetype or class + // GUID on the wire: match it against the replay's own `_ClassNetCache` + // groups by Unreal naming conventions and rebind the block to that + // group, or its RPCs are named by handle. + if is_bare_instance_name(&self.current_group_path) { + if let Some(group) = self + .cache + .resolve_cnc_for_instance_name(&self.current_group_path) + { + let len = group.len(); + let resolved = self.channel_state.names.intern(&group.path); + self.set_current_group_path(resolved); + return (len, "class_net_cache_instance_name"); + } + } + (0, "unresolved_class_net_cache") + } +} + +/// Which family of export group a block may bind to. It selects both the +/// lookup-key generator and the acceptance test, which must move together: a +/// ClassNetCache block bound to a RepLayout group reads the wrong handle width, +/// a decode failure, not a naming one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GroupKind { + RepLayout, + ClassNetCache, +} + +impl GroupKind { + fn for_block(header: &ContentBlockHeader) -> Self { + if header.has_rep_layout { + Self::RepLayout + } else { + Self::ClassNetCache + } + } + + /// Probe this kind's lookup keys in order until one is accepted. The + /// generators are visitors, so a path with no alias allocates nothing. + fn find(self, path: &str, probe: impl FnMut(&str) -> Option) -> Option { + match self { + Self::RepLayout => find_replay_path_key(path, probe), + Self::ClassNetCache => find_class_net_cache_key(path, probe), + } + } + + fn accepts(self, group: &NetFieldExportGroup) -> bool { + match self { + Self::RepLayout => true, + Self::ClassNetCache => is_class_net_cache(group), + } + } +} + +fn is_class_net_cache(group: &NetFieldExportGroup) -> bool { + group.path.ends_with(vrf_schema::CLASS_NET_CACHE_SUFFIX) +} + +/// A path with no separators and no ` bool { + !vrf_schema::has_path_separator(path) && !path.starts_with('<') +} + +/// The path's last component, after its final `/`, `.` or `:`. +fn leaf(path: &str) -> &str { + path.rfind(['/', '.', ':']).map_or(path, |i| &path[i + 1..]) +} + +/// `package_path` joined with the class name of the archetype's leaf +/// (`Default__` stripped), unless the package path already ends with it: +/// `/Game/Characters/AggroBot/AggroBot_PC` + `Default__AggroBot_PC_C`. +pub(super) fn combined_candidate( + package_path: Option<&str>, + archetype_path: Option<&str>, +) -> Option { + let pkg = package_path?; + let class_leaf = leaf(archetype_path?); + if class_leaf.is_empty() { + return None; + } + let class_name = class_leaf.strip_prefix("Default__").unwrap_or(class_leaf); + if pkg + .strip_suffix(class_name) + .is_some_and(|prefix| prefix.ends_with(['.', ':'])) + { + return Some(pkg.to_owned()); + } + Some(format!("{pkg}.{class_name}")) +} + +/// The [`KNOWN_SUBOBJECT_CLASS_PATHS`] target for `object_path`'s leaf and kind. +fn resolve_known_subobject_class_path(object_path: &str, want: GroupKind) -> Option<&'static str> { + let leaf = leaf(object_path); + KNOWN_SUBOBJECT_CLASS_PATHS + .iter() + .find(|(name, _, kind)| *name == leaf && *kind == want) + .map(|(_, class_path, _)| *class_path) +} + +/// An archetype GUID together with the actor it was read for. +/// +/// Channel indices are recycled and a *static* actor carries no archetype to +/// displace its predecessor's, so a channel-keyed archetype decodes the new +/// actor under the old class -- and nothing fails: fields get names, values get +/// types, and rows ship under a class the actor never had. Destruction retires +/// the entry; dormancy keeps it (a wake-up need not repeat the archetype), and +/// the actor stamp keeps a surviving dormant entry safe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ChannelArchetype { + actor: NetworkGuid, + archetype: NetworkGuid, +} + +/// Register an archetype for a channel and, if that changed anything, tell the +/// memo. Called from `on_actor_open`. +pub(super) fn set_channel_archetype( + state: &mut ChannelState, + channel_index: u32, + actor: NetworkGuid, + archetype: NetworkGuid, +) { + let entry = ChannelArchetype { actor, archetype }; + if state.archetypes.get(&channel_index) == Some(&entry) { + return; + } + state.archetypes.insert(channel_index, entry); + state.note_resolution_input_changed(); +} + +/// The archetype GUID recorded for `channel_index`, but only if it was read for +/// `actor`. See [`ChannelArchetype`]. +pub(super) fn channel_archetype( + state: &ChannelState, + channel_index: u32, + actor: NetworkGuid, +) -> Option { + state + .archetypes + .get(&channel_index) + .filter(|entry| entry.actor == actor) + .map(|entry| entry.archetype) +} + +/// Retire the archetype state owned by a destroyed channel index. +pub(super) fn retire_channel_archetype(state: &mut ChannelState, channel_index: u32) { + if state.archetypes.remove(&channel_index).is_some() { + state.note_resolution_input_changed(); + } +} + +#[cfg(test)] +mod tests { + #![allow(unused_must_use)] + + use super::*; + use crate::vendor::sink::test_fixtures::{Rig, actor_block, channel_open}; + use vrf_net::pipeline::ReplicationSink; + + /// Build a cache holding `groups` as declared export groups and mapping + /// `guid` to `guid_path`, then run one actor content block through a sink + /// and report the group path it resolved to. + fn actor_group_path_for( + groups: &[&str], + guid: u32, + guid_path: &str, + has_rep_layout: bool, + ) -> String { + let mut rig = Rig::default(); + for (i, path) in groups.iter().enumerate() { + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new( + (*path).to_owned(), + i as u32 + 1, + 4, + )); + } + rig.cache.set_net_guid_path(guid, guid_path, None); + let mut sink = rig.sink(); + + // No archetype is registered, so only the actor-GUID path is left. + sink.on_content_block(3, NetworkGuid(guid), &actor_block(has_rep_layout)); + sink.current_group_path.to_string() + } + + /// An actor whose own NetGUID path is a unique leaf of a declared group + /// reaches that group, as the class and subobject paths do. + #[test] + fn an_actor_path_that_is_a_unique_leaf_reaches_its_declared_group() { + assert_eq!( + actor_group_path_for( + &["/Script/ShooterGame.AresWorldSettings"], + 42, + "AresWorldSettings", + true, + ), + "/Script/ShooterGame.AresWorldSettings", + ); + } + + /// Every RepLayout pair reaches its target from a RepLayout block and stays + /// bare on a ClassNetCache block (fails if the remap stops asking the block + /// kind). Only the four effect entries remap a ClassNetCache block: another + /// would bind that leaf's RPC stream to `_ClassNetCache`, the + /// AbilitySystem mis-parse. The row count pins deletions, which + /// `tools/check_component_remaps.py` only reports. + #[test] + fn every_rep_layout_remap_reaches_its_group_and_only_from_rep_layout() { + let mut rep_layout = 0; + for (leaf, target, kind) in KNOWN_SUBOBJECT_CLASS_PATHS { + if *kind != GroupKind::RepLayout { + continue; + } + rep_layout += 1; + assert_eq!(actor_group_path_for(&[target], 100, leaf, true), *target); + assert_eq!( + actor_group_path_for(&[target], 100, leaf, false), + *leaf, + "{leaf}: a ClassNetCache block is not handed the RepLayout group", + ); + assert_eq!( + resolve_known_subobject_class_path(leaf, GroupKind::ClassNetCache), + None, + "{leaf} is RepLayout-only", + ); + } + assert_eq!(rep_layout, 49); + let class_net_cache: Vec<&str> = KNOWN_SUBOBJECT_CLASS_PATHS + .iter() + .filter(|(_, _, kind)| *kind == GroupKind::ClassNetCache) + .map(|(leaf, _, _)| *leaf) + .collect(); + assert_eq!( + class_net_cache, + [ + "ReplayEffect", + "EffectManager", + "LocationalEffectManager", + "DamageHandlerComponent", + ], + ); + } + + /// A component the table does not list keeps its bare path rather than + /// joining whichever native group looks close. + #[test] + fn an_unlisted_component_name_is_not_remapped() { + assert_eq!( + actor_group_path_for( + &["/Script/ShooterGame.AmmoComponent"], + 100, + "SomeUnlistedComponent", + true, + ), + "SomeUnlistedComponent", + ); + } + + /// Two declared groups sharing a leaf mark it `AMBIGUOUS_LEAF`: the actor + /// keeps its raw path rather than binding to whichever came first. + #[test] + fn an_ambiguous_actor_leaf_binds_to_nothing() { + assert_eq!( + actor_group_path_for( + &[ + "/Script/ShooterGame.AresWorldSettings", + "/Game/Maps/Ascent.AresWorldSettings", + ], + 42, + "AresWorldSettings", + true, + ), + "AresWorldSettings", + ); + } + + /// A ClassNetCache actor block is not captured by a RepLayout leaf; see the + /// `_ClassNetCache` guard in `resolve_actor_group_path`. + #[test] + fn a_class_net_cache_actor_block_is_not_captured_by_a_rep_layout_leaf() { + assert_eq!( + actor_group_path_for( + &["/Script/ShooterGame.AresWorldSettings"], + 42, + "AresWorldSettings", + false, + ), + "AresWorldSettings", + ); + } + + /// A cache declaring `Smoke_C`, whose archetype (GUID 8) names its class + /// through its outer (GUID 9). + fn smoke_rig() -> Rig { + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new( + "/Game/Effects/Smoke.Smoke_C".to_owned(), + 1, + 4, + )); + rig.cache + .set_net_guid_path(8, "Default__Smoke_C", Some(NetworkGuid(9))); + rig.cache.set_net_guid_path(9, "/Game/Effects/Smoke", None); + rig + } + + /// A static actor on a recycled channel is not decoded under the previous + /// actor's archetype; see [`ChannelArchetype`]. + #[test] + fn a_reused_channel_does_not_inherit_the_previous_actors_archetype() { + let mut rig = smoke_rig(); + // GUID 77 is a static actor that opens later on the same channel and + // brings no archetype with it. + rig.cache.set_net_guid_path(77, "SomeStaticProp", None); + + let mut sink = rig.sink(); + let header = actor_block(true); + + // The dynamic actor opens on channel 5 and resolves to its class. + sink.on_actor_open(&channel_open(5, 42, 8)); + sink.on_content_block(5, NetworkGuid(42), &header); + assert_eq!( + &*sink.current_group_path, "/Game/Effects/Smoke.Smoke_C", + "the dynamic actor must reach its own class", + ); + + // It closes, and the channel number is handed to a static actor. + sink.on_actor_close(5, NetworkGuid(42), false); + sink.on_actor_open(&channel_open(5, 77, 0)); + sink.on_content_block(5, NetworkGuid(77), &header); + assert_eq!( + &*sink.current_group_path, "SomeStaticProp", + "the static actor must not be decoded under the previous actor's class", + ); + } + + /// ...while the *same* actor waking from dormancy without re-sending its + /// archetype keeps its class. + #[test] + fn the_same_actor_reopening_without_an_archetype_keeps_its_class() { + let mut rig = smoke_rig(); + + let mut sink = rig.sink(); + let header = actor_block(true); + + sink.on_actor_open(&channel_open(5, 42, 8)); + sink.on_actor_close(5, NetworkGuid(42), true); + // Woken: same actor, same channel, no archetype on the wire. + sink.on_actor_open(&channel_open(5, 42, 0)); + sink.on_content_block(5, NetworkGuid(42), &header); + assert_eq!( + &*sink.current_group_path, "/Game/Effects/Smoke.Smoke_C", + "a dormancy wake must not lose the actor's class", + ); + } + + /// The memo never answers for a block whose inputs moved: a GUID path + /// declared through `register_path`, or written by frame-level ExportData + /// straight into the cache between two sinks, moves only + /// `NetGuidCache::guid_generation`. + #[test] + fn a_guid_path_registration_invalidates_the_memo() { + use vrf_net::net_guid::GuidPathSink; + + for through_sink in [true, false] { + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new( + "/Script/ShooterGame.AresWorldSettings".to_owned(), + 1, + 4, + )); + let header = actor_block(true); + let mut sink = rig.sink(); + sink.on_content_block(3, NetworkGuid(42), &header); + assert_eq!(&*sink.current_group_path, ""); + if through_sink { + sink.register_path(42, "AresWorldSettings", NetworkGuid(0)); + } else { + rig.cache.set_net_guid_path(42, "AresWorldSettings", None); + } + let mut sink = rig.sink(); + sink.on_content_block(3, NetworkGuid(42), &header); + assert_eq!( + &*sink.current_group_path, "/Script/ShooterGame.AresWorldSettings", + "through the sink: {through_sink}" + ); + } + } + + /// A repeat registration that changes nothing leaves the memo standing (if + /// every re-declaration moved a stamp, the hit rate would collapse). The + /// asserts read the memo's hit flag, so they hold whichever stamp moves. + #[test] + fn a_redundant_registration_leaves_the_memo_alone() { + use vrf_net::net_guid::GuidPathSink; + + let mut rig = Rig::default(); + let mut sink = rig.sink(); + let header = actor_block(true); + + sink.register_path(42, "AresWorldSettings", NetworkGuid(7)); + sink.on_content_block(3, NetworkGuid(42), &header); + assert!(!sink.current_resolution_memo_hit, "a first resolution hit"); + sink.register_path(42, "AresWorldSettings", NetworkGuid(7)); + sink.on_content_block(3, NetworkGuid(42), &header); + assert!( + sink.current_resolution_memo_hit, + "an unchanged re-declaration invalidated the memo" + ); + + // A different outer is a real change (the `outer_net_guid` column and a + // resolution input): an invalid outer removes the one the cache held. + sink.register_path(42, "AresWorldSettings", NetworkGuid(0)); + sink.on_content_block(3, NetworkGuid(42), &header); + assert!( + !sink.current_resolution_memo_hit, + "a changed outer left the memo standing" + ); + assert_eq!(sink.cache.get_outer_guid(42), None); + } +} diff --git a/native/replay/src/vendor/sink/rpc.rs b/native/replay/src/vendor/sink/rpc.rs new file mode 100644 index 000000000..9e3341fa8 --- /dev/null +++ b/native/replay/src/vendor/sink/rpc.rs @@ -0,0 +1,762 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/rpc.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! The ClassNetCache RPC payload walker. A ClassNetCache block carries function +//! calls, each payload a sub-archive in the RepLayout `FunctionParameters` +//! grammar; walking it turns one opaque blob into one row per named parameter +//! (their share of all rows: docs/PERFORMANCE_NOTES.md#rpc-parameter-walking). + +use std::collections::HashSet; +use std::sync::Arc; + +use smallvec::{SmallVec, smallvec}; +use vrf_bitio::BitReader; +use vrf_decode::{ + ArrayFieldSchema, DecodeErrorKind, EffectArrayKind, EffectBlobError, FieldType, + LIFE_CHANGE_BY_SECTION_SCHEMA, LIFE_CHANGE_DAMAGE_SCHEMA, LIFE_CHANGE_SECTION_SCHEMA, + apply_overlay_with_checksum, decode_effect_blob_json, decode_struct_array, + decode_struct_array_exact, group_hash_state, +}; +use vrf_schema::{FxHashMap, NetGuidCache}; + +use super::intern::put; +use super::{ExportSink, FieldValues, MeasuredArrayRoute, TABLE}; + +/// Memo for [`ExportSink::find_rpc_param_group_path`], a pure function of (block +/// group path, function name, declared group paths); clearing it when +/// `NetGuidCache::schema_generation` moves keeps it exact. It spares a scan of +/// every declared group per RPC. The outer key is the interned group path, so a +/// hit allocates nothing. +#[derive(Debug, Clone, Default)] +pub(super) struct RpcParamGroupMemo { + generation: u64, + by_group: FxHashMap, FxHashMap>>>, +} + +impl ExportSink<'_> { + /// Walk an RPC payload as a RepLayout parameter stream, naming each + /// parameter from the group [`Self::compute_rpc_param_group_path`] finds, or + /// `{func}._h{N}` without one. + /// + /// The `FunctionParameters` grammar: + /// ```text + /// propertyChecksum : 1 bit (ignored) + /// loop: + /// encodedHandle : IntPacked + /// [if 0 -> break] + /// handle = encodedHandle - 1 + /// payloadBits : IntPacked + /// fieldPayload : sub-reader of payloadBits bits + /// ``` + /// plus one trailing alignment bit, consumed when exactly one remains + /// (`FunctionParameters && BitsRemaining == 1 -> SkipBits(1)`). + /// + /// `false` only when no parameter row was emitted (the caller then emits the + /// raw row). A walk that breaks on a malformed read keeps its rows and bumps + /// `truncated_rpcs`; after such a break, or a suffix past the terminator, the + /// whole payload is added as one more row. + pub(super) fn try_parse_rpc_params( + &mut self, + rpc_handle: u32, + reader: BitReader<'_>, + function_name: Option<&str>, + ) -> bool { + let Some(func_name) = function_name else { + return false; + }; + let whole_reader = reader.clone(); + let Ok(whole_bit_count) = u32::try_from(reader.bits_remaining()) else { + return false; + }; + + let param_group_path = self.find_rpc_param_group_path(func_name); + let mut rpc_reader = reader; + + if rpc_reader.read_bit().is_err() { + return false; + } + self.stats.rpc_param_walks += 1; + + let mut emitted_any = false; + // Set only on the three malformed-read breaks below, never on the + // normal exits (end of stream, alignment bit, zero-handle terminator). + let mut truncated = false; + let mut unexplained_suffix = false; + let param_group_path_ref = param_group_path.as_deref(); + + loop { + if rpc_reader.at_end() { + break; + } + + if rpc_reader.bits_remaining() == 1 { + let _ = rpc_reader.read_bit(); + break; + } + + let Ok(encoded_handle) = rpc_reader.read_int_packed() else { + truncated = true; + break; + }; + if encoded_handle == 0 { + // "No more parameters": one trailing alignment bit is grammar, + // anything more is a suffix this walk cannot explain, counted + // (not rejected; see `ExportStats::rpc_suffix_bits_dropped`). + let leftover = rpc_reader.bits_remaining(); + if leftover > 1 { + self.stats.rpc_suffix_bits_dropped = + self.stats.rpc_suffix_bits_dropped.saturating_add(leftover); + unexplained_suffix = true; + } + break; + } + + let param_handle = encoded_handle - 1; + let Ok(payload_bits) = rpc_reader.read_int_packed() else { + truncated = true; + break; + }; + + // Fails when more bits are declared than remain. + let Ok(sub) = rpc_reader.sub_reader(u64::from(payload_bits)) else { + truncated = true; + break; + }; + + // One schema walk for the name and the overlay's last-resort checksum. + let param_export = param_group_path_ref.and_then(|gp| { + self.cache + .get_group_by_path(gp) + .and_then(|g| g.get_field(param_handle)) + }); + let param_name: Option<&str> = param_export.map(|f| f.name.as_str()); + let param_checksum: Option = param_export.map(|f| f.compatible_checksum); + + // "Function.Param", or "Function._h{N}" unnamed: a dot, because group + // paths already use ':' and downstream splits on the first '.'. + let full_field_name = self.channel_state.names.intern_fmt(|out| match param_name { + Some(pn) => put(out, format_args!("{func_name}.{pn}")), + None => put(out, format_args!("{func_name}._h{param_handle}")), + }); + + let raw_bits = copy_raw_bits(sub, payload_bits); + + // The overlay keys on the parameter group; only the + // `current_group_path` fallback has a cached hash. + let overlay_group = param_group_path_ref.unwrap_or(&self.current_group_path); + let group_state = match param_group_path_ref { + Some(gp) => group_hash_state(gp), + None => self.current_group_hash, + }; + // `param_name`, not `full_field_name`: the synthesized `{func}._h{N}` + // is no wire-declared name, and passed as one it would trip + // `resolve_in_group`'s conflict guard against its own placeholder, + // refusing the handle fallback that guard exists to allow. + let (value_i64, value_f64, value_bool, mut value_str) = apply_overlay_with_checksum( + &TABLE, + overlay_group, + group_state, + param_name, + param_handle, + param_checksum, + raw_bits.as_deref(), + payload_bits, + &mut self.stats.overlay, + ) + .map(|result| result.into_columns()) + .unwrap_or_default(); + + // Additive pass: an EffectContainer array the overlay left untyped + // becomes a JSON `value_str`, `raw_bits` kept. A declared type that + // decoded outranks this name-driven decode. The match is on the + // parameter name: a `_h{N}` handle does not identify the element type. + if value_i64.is_none() + && value_f64.is_none() + && value_bool.is_none() + && value_str.is_none() + { + if let (Some(kind), Some(raw)) = ( + param_name.and_then(EffectArrayKind::from_param_name), + raw_bits.as_deref(), + ) { + // `payload_bits`, not `raw.len() * 8`: the padding is not data. + match decode_effect_blob_json(kind, raw, payload_bits) { + Ok(json) => { + value_str = Some(json); + self.stats.effect_blobs_decoded += 1; + } + Err(err) => { + // Reaches "Decode errors": `stats.overlay` is the only + // channel that survives the packet, so "Rows offered" + // over-reports by the failure count (0 measured). + self.stats.overlay.decoded_err += 1; + self.stats.overlay.error_report.record( + overlay_group, + &full_field_name, + FieldType::Raw, + payload_bits, + effect_error_kind(&err), + ); + } + } + } + } + + let targeting_world_location_array = func_name == "MulticastRespondToValidMapClick" + && param_handle == 0 + && param_name == Some("WorldLocation") + && param_checksum == Some(2052180909) + && param_group_path_ref + == Some( + "/Script/ShooterGame.MapTargetingStateComponent:MulticastRespondToValidMapClick", + ) + && param_group_path_ref + .and_then(|path| self.cache.get_group_by_path(path)) + .and_then(|group| group.get_field(1)) + .is_some_and(|field| { + field.name == "WorldLocation" && field.compatible_checksum == 3965480401 + }); + let projectile_path_array = self.admits(MeasuredArrayRoute::NetworkedProjectilePath) + && self.current_group_path.as_ref() + == "/Script/ShooterGame.PrecalculatedProjectileMovementComponent_ClassNetCache" + && param_group_path_ref + == Some( + "/Script/ShooterGame.PrecalculatedProjectileMovementComponent:MulticastSetPath", + ) + && func_name == "MulticastSetPath" + && param_handle == 0 + && param_name == Some("NetworkedProjectilePath") + && param_checksum == Some(2_930_105_559); + + // Additive pass: the life-change arrays, outside the value gate on + // purpose. That gate ranks decodes of the same bits; this pass only + // adds rows, so typing the parent one day must not stop the children. + if let (Some(schema), Some(raw)) = ( + life_change_schema_for_param(func_name, param_name), + raw_bits.as_deref(), + ) { + self.emit_life_change_array( + schema, + &full_field_name, + rpc_handle, + raw, + payload_bits, + ); + } + + // The multi-click RPC's flat array of 192-bit world locations. Every + // identity is the replay's declaration, so a lookalike stays raw. + if let (true, Some(raw)) = (targeting_world_location_array, raw_bits.as_deref()) { + self.stats.targeting_world_locations_decoded += self.emit_exact_array_leaves( + &full_field_name, + rpc_handle, + (raw, payload_bits), + &[None, Some("WorldLocation")], + &[(1, 192, ".WorldLocation", FieldType::VectorDouble)], + ); + } + + // The projectile path's element handles come from the PathPoint + // descriptor (the replay's handles 1-3 are unrelated siblings), so + // the route is scoped to the observed parent identity. + if let (true, Some(raw)) = (projectile_path_array, raw_bits.as_deref()) { + if super::blobs::strict_nested_array_preflight(raw, payload_bits, &[1, 2, 3]) { + self.stats.route_children_projectile_path += self.emit_exact_array_leaves( + &full_field_name, + rpc_handle, + (raw, payload_bits), + &[ + None, + Some("ElapsedSeconds"), + Some("Location"), + Some("Velocity"), + ], + &[ + (1, 32, ".ElapsedSeconds", FieldType::Float), + (2, 192, ".Location", FieldType::VectorDouble), + (3, 192, ".Velocity", FieldType::VectorDouble), + ], + ); + } else { + self.stats.array.errors += 1; + } + } + + self.push_field(FieldValues { + handle: rpc_handle, + field_name: Some(full_field_name), + compatible_checksum: param_checksum, + bit_count: payload_bits, + raw_bits, + value_i64, + value_f64, + value_bool, + value_str, + }); + + emitted_any = true; + } + + if truncated { + self.stats.truncated_rpcs = self.stats.truncated_rpcs.saturating_add(1); + } + + // Emitted parameter rows cannot stand in for bits the walk did not + // explain, and they suppress the caller's raw fallback, so add the + // whole payload as one more row; the typed rows stay. + if emitted_any && (truncated || unexplained_suffix) { + let field_name = self.channel_state.names.intern(func_name); + self.push_field(FieldValues { + handle: rpc_handle, + field_name: Some(field_name), + bit_count: whole_bit_count, + raw_bits: copy_raw_bits(whole_reader, whole_bit_count), + ..FieldValues::default() + }); + } + + emitted_any + } + + /// Emit one row per member of a life-change array element; additive, the + /// caller pushes the parent row either way. The rows carry `rpc_handle`, not + /// the member's own handle: `tools/to_valplay_bundle.py` groups a call's + /// parameters by `(packet, actor, group, handle)`, so member handles would + /// split one call into several events. + fn emit_life_change_array( + &mut self, + schema: &'static ArrayFieldSchema, + prefix: &str, + rpc_handle: u32, + raw: &[u8], + bit_count: u32, + ) { + let flattened = + decode_struct_array(raw, bit_count, Some(schema), &[], &mut self.stats.array); + for field in &flattened { + let columns = match life_change_member_type(&field.path) { + Some(ft) => super::blobs::decode_leaf_with_stats( + ft, + &field.raw_bits, + field.bit_count, + &mut self.stats.array_leaf_decode_errors, + ), + None => (None, None, None, None), + }; + self.push_child( + rpc_handle, + &[prefix, &field.path], + field.bit_count, + &field.raw_bits, + columns, + ); + } + } + + /// Emit an RPC struct array's leaves when every element holds exactly + /// `members` (handle, width, path suffix, type), else none: a walker + /// diagnostic, a short element (the walker calls a missing member clean), an + /// unexpected leaf, a duplicate path or a non-finite number keeps only the + /// raw parent. A refused clean walk counts once in + /// `array_leaf_decode_errors`. Returns the children pushed. + fn emit_exact_array_leaves( + &mut self, + prefix: &str, + rpc_handle: u32, + (raw, bit_count): (&[u8], u32), + declared: &[Option<&str>], + members: &[(u32, u32, &str, FieldType)], + ) -> u64 { + let mut isolated = vrf_decode::ArrayDecodeStats::default(); + let flattened = decode_struct_array_exact(raw, bit_count, declared, &mut isolated); + self.stats.array.merge_from(&isolated); + if !isolated.is_clean() { + return 0; + } + let len = flattened.len() as u64; + let complete = isolated.fields_emitted == len + && isolated + .elements_decoded + .saturating_mul(members.len() as u64) + == len; + let prior_errors = self.stats.array_leaf_decode_errors; + let mut paths = HashSet::new(); + let children: Option> = complete + .then(|| { + flattened + .into_iter() + .map(|field| { + let &(.., kind) = members.iter().find(|&&(handle, width, suffix, _)| { + field.handle == handle + && field.bit_count == width + && field.path.ends_with(suffix) + })?; + let finite = kind != FieldType::VectorDouble + || field.raw_bits.chunks_exact(8).all(|chunk| { + f64::from_le_bytes(chunk.try_into().expect("eight-byte chunk")) + .is_finite() + }); + if !finite || !paths.insert(field.path.clone()) { + return None; + } + let columns = super::blobs::decode_leaf_with_stats( + kind, + &field.raw_bits, + field.bit_count, + &mut self.stats.array_leaf_decode_errors, + ); + let typed = match kind { + FieldType::Float => columns.1.is_some_and(f64::is_finite), + _ => columns.3.is_some(), + }; + typed.then_some((field, columns)) + }) + .collect() + }) + .flatten(); + let Some(children) = children else { + if self.stats.array_leaf_decode_errors == prior_errors { + self.stats.array_leaf_decode_errors += 1; + } + return 0; + }; + let pushed = children.len() as u64; + for (field, columns) in children { + self.push_child( + rpc_handle, + &[prefix, &field.path], + field.bit_count, + &field.raw_bits, + columns, + ); + } + pushed + } + + /// [`Self::compute_rpc_param_group_path`], memoised in [`RpcParamGroupMemo`]. + fn find_rpc_param_group_path(&mut self, function_name: &str) -> Option> { + let Self { + cache, + channel_state, + current_group_path, + .. + } = self; + let memo = &mut channel_state.rpc_param_groups; + let generation = cache.schema_generation(); + if memo.generation != generation { + memo.by_group.clear(); + memo.generation = generation; + } + + if let Some(by_function) = memo.by_group.get(&**current_group_path) { + if let Some(hit) = by_function.get(function_name) { + return hit.clone(); + } + } + + let resolved = Self::compute_rpc_param_group_path(cache, current_group_path, function_name); + memo.by_group + // Cloning the interned handle, not the string. + .entry(Arc::clone(current_group_path)) + .or_default() + .insert(function_name.to_owned(), resolved.clone()); + resolved + } + + /// The RPC's parameter group: `:` from the block's + /// `_ClassNetCache` group when the class itself declares it (e.g. + /// `DamageableComponent`), else the one declared group ending in + /// `:`, which covers inheritance (`Wushu_PC_C_ClassNetCache`'s + /// `MulticastNotifyKilledEnemy` is declared as + /// `ShooterCharacter:MulticastNotifyKilledEnemy`). Most of the 84 RPC + /// parameter groups have a unique function name; an ambiguous one is `None`, + /// and its parameters are named `{func}._h{N}`. + fn compute_rpc_param_group_path( + cache: &NetGuidCache, + current_group_path: &str, + function_name: &str, + ) -> Option> { + if let Some(base) = current_group_path.strip_suffix(vrf_schema::CLASS_NET_CACHE_SUFFIX) { + let candidate = format!("{base}:{function_name}"); + if cache.get_group_by_path(&candidate).is_some() { + return Some(Arc::from(candidate)); + } + } + + let suffix = format!(":{function_name}"); + let mut found: Option<&str> = None; + for group in cache.groups() { + if group.path.ends_with(&suffix) { + if found.is_some() { + // Ambiguous: multiple groups match this function name. + return None; + } + found = Some(&group.path); + } + } + found.map(Arc::from) + } +} + +/// Copy `bit_count` bits out of `reader` into a fresh byte buffer. `None` for a +/// zero-bit field -- an empty blob is not "no payload" to the valplay adapter, +/// whose capture predicate keys on `raw_bits` -- and for a short reader. +pub(super) fn copy_raw_bits( + mut reader: BitReader<'_>, + bit_count: u32, +) -> Option> { + if bit_count == 0 { + return None; + } + let mut buf = smallvec![0u8; (bit_count as usize).div_ceil(8)]; + reader.copy_bits_to(&mut buf, u64::from(bit_count)).ok()?; + Some(buf) +} + +/// Which life-change schema an RPC parameter takes, if any. Keyed on the +/// function too: each struct array has its own handle space, so the same four +/// members sit at 10-13, 1-4 or 2-5. `MulticastNotifyHeal` and +/// `MulticastNotifyOverhealDecay`, more than half the calls, name theirs +/// `LifeChangeBySection`, not `LifeChangeEvents`. +/// `MulticastReceivePlayerTemporaryDeathEvent_Point` and +/// `MulticastReceivePlayerDownedEvent_Point` (the same array at 12-15) are left +/// out: 9 and 2 calls across twenty replays cannot check a schema. +fn life_change_schema_for_param( + function_name: &str, + param_name: Option<&str>, +) -> Option<&'static ArrayFieldSchema> { + match (function_name, param_name?) { + ("MulticastNotifyDamage_Point" | "MulticastNotifyDamage_Base", "LifeChangeEvents") => { + Some(&LIFE_CHANGE_DAMAGE_SCHEMA) + } + ("MulticastSectionLifeChange", "LifeChangeEvents") => Some(&LIFE_CHANGE_SECTION_SCHEMA), + ("MulticastNotifyHeal" | "MulticastNotifyOverhealDecay", "LifeChangeBySection") => { + Some(&LIFE_CHANGE_BY_SECTION_SCHEMA) + } + _ => None, + } +} + +/// The member type of a life-change leaf, matched on the schema's name (handles +/// move between functions, names do not). `ChangedComponent` resolves through +/// `net_guids` to a `*DamageSection` actor on >99.98% of rows, `sum(DeltaLife)` +/// matches the RPC's own scalar total on 69,818 of 69,818 calls, and +/// `bAliveAfterChange` is one bit on every row and agrees with the sibling +/// `bAliveAfterDamage` 17,550/17,550. +fn life_change_member_type(path: &str) -> Option { + if path.ends_with("ChangedComponent") { + Some(FieldType::ObjectNetGuid) + } else if path.ends_with("LifeResult") || path.ends_with("DeltaLife") { + Some(FieldType::Float) + } else if path.ends_with("bAliveAfterChange") { + Some(FieldType::Bool) + } else { + None + } +} + +/// Map an effect-blob failure onto the overlay report's error kinds, which must +/// mean the same here as for overlay failures: the kind is the report's only +/// "why" column. No wildcard, so a new `EffectBlobError` does not compile until +/// it is classified. +fn effect_error_kind(err: &EffectBlobError) -> DecodeErrorKind { + match err { + EffectBlobError::BitIo(bit) => DecodeErrorKind::from_bit_error(bit), + // The bits ran out before the structure did: the window ended before + // the terminator, or a member's type read past the end of its own + // field. + EffectBlobError::MissingTerminator { .. } | EffectBlobError::PayloadOverread { .. } => { + DecodeErrorKind::Eof + } + // Bits the structure did not account for, after the terminator or + // inside a field whose type read short of it. + EffectBlobError::ResidualBits { .. } | EffectBlobError::PayloadUnderread { .. } => { + DecodeErrorKind::Residual + } + // Decoded values refused: a count over the configured maximum, a + // float JSON cannot carry. + EffectBlobError::ArrayCountTooLarge { .. } | EffectBlobError::NonFiniteFloat { .. } => { + DecodeErrorKind::Rejected + } + // The bits break a rule of this framing. `PayloadTooLarge` is a field + // width past the window or the decoder's 65,536-bit cap, refused before + // the payload is read: an overlong length prefix, `Malformed` for an + // overlay string or byte array too, not a reader running out. + EffectBlobError::PayloadTooLarge { .. } + | EffectBlobError::IndexOutOfBounds { .. } + | EffectBlobError::NonAscendingIndex { .. } + | EffectBlobError::TooManyFields { .. } + | EffectBlobError::BitLengthExceedsBuffer { .. } + | EffectBlobError::UnexpectedPayloadWidth { .. } + | EffectBlobError::ElementFieldCount { .. } + | EffectBlobError::NonAdjacentHandles { .. } + | EffectBlobError::InconsistentHandleBase { .. } + | EffectBlobError::NonZeroTerminator { .. } => DecodeErrorKind::Malformed, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use vrf_bitio::BitError; + + use super::{EffectBlobError, effect_error_kind}; + + /// Effect-blob failures share the overlay's error report, so each cause + /// prints its own label. Every variant has a case, checked against the list + /// the variant match below is generated from: the match has no wildcard, so + /// a new variant does not compile until listed, and a listed variant without + /// a case fails here. + #[test] + fn effect_failures_print_the_label_of_their_cause() { + // Generated from one list, so the list and the match cannot disagree. + macro_rules! variants { + ($($variant:ident),+ $(,)?) => { + ( + [$(stringify!($variant)),+], + |err: &EffectBlobError| -> &'static str { + match err { + $(EffectBlobError::$variant { .. } => stringify!($variant),)+ + } + }, + ) + }; + } + let (every_variant, variant_of) = variants!( + BitIo, + ArrayCountTooLarge, + IndexOutOfBounds, + NonAscendingIndex, + PayloadTooLarge, + TooManyFields, + BitLengthExceedsBuffer, + ResidualBits, + NonFiniteFloat, + UnexpectedPayloadWidth, + ElementFieldCount, + NonAdjacentHandles, + InconsistentHandleBase, + PayloadOverread, + PayloadUnderread, + MissingTerminator, + NonZeroTerminator, + ); + let cases = [ + ( + EffectBlobError::BitIo(BitError::Eof { + position: 0, + length: 8, + requested: 8, + }), + "EOF", + ), + ( + EffectBlobError::BitIo(BitError::MalformedIntPacked { position: 0 }), + "Malformed", + ), + ( + EffectBlobError::BitIo(BitError::InvalidString { position: 0 }), + "Malformed", + ), + ( + EffectBlobError::MissingTerminator { context: "array" }, + "EOF", + ), + ( + EffectBlobError::PayloadOverread { + declared: 16, + consumed: 32, + }, + "EOF", + ), + (EffectBlobError::ResidualBits { remaining: 16 }, "Residual"), + ( + EffectBlobError::PayloadUnderread { + declared: 32, + consumed: 16, + }, + "Residual", + ), + (EffectBlobError::NonFiniteFloat { index: 0 }, "Rejected"), + ( + EffectBlobError::ArrayCountTooLarge { + count: 300, + max: 256, + }, + "Rejected", + ), + (EffectBlobError::NonZeroTerminator { value: 1 }, "Malformed"), + (EffectBlobError::ElementFieldCount { found: 3 }, "Malformed"), + // A declared width past the window is an overlong length prefix, + // `Malformed` like an overlay string's, not an EOF. + ( + EffectBlobError::PayloadTooLarge { + bits: 64, + remaining: 32, + }, + "Malformed", + ), + ( + EffectBlobError::IndexOutOfBounds { index: 2, count: 2 }, + "Malformed", + ), + ( + EffectBlobError::NonAscendingIndex { + index: 0, + previous: 0, + }, + "Malformed", + ), + ( + EffectBlobError::TooManyFields { context: "element" }, + "Malformed", + ), + ( + EffectBlobError::BitLengthExceedsBuffer { + bits: 64, + available: 32, + }, + "Malformed", + ), + ( + EffectBlobError::UnexpectedPayloadWidth { + context: "float value", + expected: 32, + found: 16, + }, + "Malformed", + ), + ( + EffectBlobError::NonAdjacentHandles { + first: 1, + second: 3, + }, + "Malformed", + ), + ( + EffectBlobError::InconsistentHandleBase { + expected: 1, + found: 3, + }, + "Malformed", + ), + ]; + let printed: Vec<(String, String)> = cases + .iter() + .map(|(err, _)| (format!("{err:?}"), effect_error_kind(err).to_string())) + .collect(); + let wanted: Vec<(String, String)> = cases + .iter() + .map(|(err, want)| (format!("{err:?}"), (*want).to_owned())) + .collect(); + assert_eq!(printed, wanted); + + let reached: BTreeSet<&str> = cases.iter().map(|(err, _)| variant_of(err)).collect(); + assert_eq!( + reached, + every_variant.into_iter().collect::>(), + "every EffectBlobError needs a case" + ); + } +} diff --git a/native/replay/src/vendor/sink/stream.rs b/native/replay/src/vendor/sink/stream.rs new file mode 100644 index 000000000..5a6610571 --- /dev/null +++ b/native/replay/src/vendor/sink/stream.rs @@ -0,0 +1,2062 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/sink/stream.rs. Apache-2.0; +// see native/replay/NOTICE.md. Local edits: listed in src/vendor/mod.rs. + +//! The `vrf-net` callbacks: `FieldSink` takes replicated properties and RPCs, +//! `ReplicationSink` actor lifecycle, content-block framing and the failure +//! paths. Every row goes through `ExportSink::push_field`, so the six +//! block-context columns are stamped in exactly one place. + +use std::sync::Arc; + +use smallvec::SmallVec; +use vrf_bitio::BitReader; +use vrf_decode::apply_overlay_with_checksum; +use vrf_decode::cnc::{CncRpc, decode_cnc_payload}; +use vrf_export::{ + ActorRecord, CheckpointBlockRecord, MovementRecord, PartialRecord, + UNRESOLVED_CLASS_NET_CACHE_PAYLOAD_FIELD_NAME, +}; +use vrf_net::content::ContentBlockHeader; +use vrf_net::field::FieldSink; +use vrf_net::pipeline::{ + ActorChannelState, PartialPayloadReason, RejectedPartialFragment, RepLayoutTailOutcome, + ReplicationSink, StreamFailure, StreamFailureCause, +}; +use vrf_net::types::{FVector, NetworkGuid}; + +use super::intern::put; +use super::paths::{ + channel_archetype, combined_candidate, retire_channel_archetype, set_channel_archetype, +}; +use super::rpc::copy_raw_bits; +use super::{ExportSink, FieldValues, TABLE}; + +/// The RPC whose payload is a movement batch rather than a parameter list. +const MOVEMENT_RPC: &str = "ReplaysClientReceiveRemoteCharacterUpdatesSingleArrayNoAutonomous"; +const ABILITIES_AND_BUFFS_COMPONENT: &str = "AbilitiesAndBuffsComponent"; +const CHAINED_CNC_H1_FIELD_NAME: &str = "__vrfkit_chained_cnc_h1__"; +const UNPARSED_REP_LAYOUT_TAIL_FIELD_NAME: &str = "__vrfkit_unparsed_rep_layout_tail__"; + +impl ExportSink<'_> { + fn record_checkpoint_block( + &mut self, + channel_index: u32, + actor_net_guid: NetworkGuid, + header: &ContentBlockHeader, + function_count: u32, + resolved: bool, + ) { + let Some((checkpoint, field_offset, block_offset)) = self.checkpoint_block_scope.clone() + else { + return; + }; + let (actor_archetype_outer_path, actor_archetype_path) = if header.is_actor { + let archetype = channel_archetype(self.channel_state, channel_index, actor_net_guid); + self.archetype_paths(archetype) + } else { + (None, None) + }; + let cache = &*self.cache; + let guid_path = |guid: u32| cache.get_path_by_guid(guid).map(str::to_owned); + let object_guid = (!header.is_actor).then_some(header.object_net_guid.0); + let block_index = block_offset + self.records.checkpoint_blocks.len() as u32; + let field_row_start = field_offset + self.records.fields.len() as u64; + self.records.checkpoint_blocks.push(CheckpointBlockRecord { + checkpoint, + block_index, + time_ms: self.time_ms, + packet_id: self.packet_id, + channel_index, + actor_net_guid: actor_net_guid.0, + object_net_guid: object_guid, + class_net_guid: header.has_class_net_guid.then_some(header.class_net_guid.0), + outer_net_guid: Some(header.outer_net_guid.0), + has_rep_layout: header.has_rep_layout, + is_actor: header.is_actor, + is_deleted: header.is_deleted, + is_stably_named: header.is_stably_named, + delete_flags: header.delete_flags, + resolved_group_path: if resolved { + Arc::clone(&self.current_group_path) + } else { + Arc::from("") + }, + group_resolution_source: if resolved { + self.current_group_resolution_source + } else { + "not_resolved_deleted" + }, + group_declared: resolved && cache.get_group_by_path(&self.current_group_path).is_some(), + resolution_memo_hit: resolved && self.current_resolution_memo_hit, + function_count, + function_count_source: if resolved { + self.current_function_count_source + } else { + "not_applicable_deleted" + }, + actor_archetype_path, + actor_archetype_outer_path, + actor_guid_path: guid_path(actor_net_guid.0), + class_guid_path: header + .has_class_net_guid + .then(|| guid_path(header.class_net_guid.0)) + .flatten(), + object_guid_path: object_guid.and_then(guid_path), + object_outer_path: object_guid + .and_then(|guid| cache.get_outer_path(guid)) + .map(str::to_owned), + field_row_start, + field_row_count: 0, + }); + } + + /// The current group's name and `compatible_checksum` for `handle`, from one + /// schema walk (the checksum feeds the overlay's last-resort lookup; asking + /// separately would double the hottest loop's cost). The name is interned. + fn resolve_field_name_and_checksum(&mut self, handle: u32) -> (Option>, Option) { + let Self { + cache, + channel_state, + current_group_path, + .. + } = self; + if let Some(group) = cache.get_group_by_path(current_group_path) { + if let Some(field) = group.get_field(handle) { + return ( + Some(channel_state.names.intern(field.name.as_str())), + Some(field.compatible_checksum), + ); + } + } + // Groups declared without field names (e.g. `MagazineAmmo`) fall back to + // the overlay's handle table, or the row stays unnamed though typed. + let Some(name) = TABLE.lookup_handle(current_group_path, handle) else { + return (None, None); + }; + (Some(channel_state.names.intern(name)), None) + } +} + +impl FieldSink for ExportSink<'_> { + fn on_field(&mut self, handle: u32, bit_count: u32, reader: BitReader<'_>) { + let (field_name, field_checksum) = self.resolve_field_name_and_checksum(handle); + let raw_bits = copy_raw_bits(reader, bit_count); + + // Additive passes, each a no-op unless it owns the field; the parent row + // with the whole payload is still emitted below. + if let Some(raw) = raw_bits.as_deref() { + let name = field_name.as_deref(); + self.emit_flattened_array(name, field_checksum, raw, bit_count); + self.decode_struct_blob(name, raw, bit_count); + self.emit_multi_contents(name, raw, bit_count); + } + + let (value_i64, value_f64, value_bool, value_str) = apply_overlay_with_checksum( + &TABLE, + &self.current_group_path, + self.current_group_hash, + field_name.as_deref(), + handle, + field_checksum, + raw_bits.as_deref(), + bit_count, + &mut self.stats.overlay, + ) + .map(|result| result.into_columns()) + .unwrap_or_default(); + + self.record_player_identity(field_name.as_deref(), value_str.as_deref(), value_i64); + + self.push_field(FieldValues { + handle, + field_name, + compatible_checksum: field_checksum, + bit_count, + raw_bits, + value_i64, + value_f64, + value_bool, + value_str, + }); + } + + fn on_rpc(&mut self, handle: u32, bit_count: u32, reader: BitReader<'_>) { + let field_name = self.resolve_field_name_and_checksum(handle).0; + + if field_name.as_deref() == Some(MOVEMENT_RPC) && bit_count > 0 { + let fallback_reader = reader.clone(); + let failed = self.decode_movement_rpc(reader); + // A clean batch is movement.parquet row for row; a failed or partial + // one cannot reproduce its input, so the whole payload is kept. Bits + // a section leaves unread in a clean batch are only tallied + // (`movement_*_section_tail*`). + self.push_field(FieldValues { + handle, + field_name, + bit_count, + raw_bits: failed + .then(|| copy_raw_bits(fallback_reader, bit_count)) + .flatten(), + ..FieldValues::default() + }); + } else if bit_count > 0 { + let fallback_reader = reader.clone(); + let parsed = self.try_parse_rpc_params(handle, reader, field_name.as_deref()); + if !parsed { + // Nothing walked (no function name, or no parameter before the + // stream ended): one raw row for the whole payload. + self.push_field(FieldValues { + handle, + field_name, + bit_count, + raw_bits: copy_raw_bits(fallback_reader, bit_count), + ..FieldValues::default() + }); + } + } else { + // A zero-bit RPC: one marker row. + self.push_field(FieldValues { + handle, + field_name, + ..FieldValues::default() + }); + } + self.stats.rpcs_emitted += 1; + } +} + +/// Bomb-mode PlayerState, whose `Subject` and `SpawnedCharacter` feed the +/// manifest `players` array (see `PlayerIdentity`). +const BOMB_PLAYER_STATE: &str = "/Game/GameModes/Bomb/BombPlayerState.BombPlayerState_C"; + +/// The ClassNetCache function count for `AbilitiesAndBuffsComponent`, whose +/// `_ClassNetCache` group no VALORANT replay declares: the minimum fc that walks +/// all 9,274 payloads of a reference replay as one handle-1 RPC (every fc in +/// 34-65 gives handle 1 the same 6-bit width). One constant, not a per-payload +/// search: smaller fc values also walk simple payloads, with garbage handles. +/// A clean walk proves neither the width nor the group (the inner FastArray +/// framing is the stronger evidence: `tools/extract_fastarray_observations.py`), +/// so consumers keep the raw parent and validate the inner structure. +const ABILITIES_AND_BUFFS_FC: u32 = 34; + +impl ExportSink<'_> { + /// Decode a movement RPC payload into `movement.parquet` rows. + fn decode_movement_rpc(&mut self, reader: BitReader<'_>) -> bool { + let mut rpc_reader = reader; + let time_ms = self.time_ms; + let packet_id = self.packet_id; + let movement = &mut self.records.movement; + let result = vrf_movement::decode_movement_rpc(&mut rpc_reader, |mv| { + movement.push(MovementRecord { + time_ms, + packet_id, + character_net_guid: mv.shooter_character_net_guid, + pos_x: mv.pos_x as f32, + pos_y: mv.pos_y as f32, + pos_z: mv.pos_z as f32, + yaw: mv.yaw as f32, + pitch: mv.pitch as f32, + vel_x: mv.vel_x as f32, + vel_y: mv.vel_y as f32, + vel_z: mv.vel_z as f32, + timestamp: mv.timestamp, + movement_state: mv.movement_state, + // mv.mode_flags is intentionally not carried: the decoder + // assigns it from the same local as movement_state, so it + // can never hold a different value. + move_type: mv.move_type, + }); + }); + let failed = match &result { + Ok(decoded) => decoded.error_count != 0, + Err(_) => true, + }; + self.stats.record_movement_decode(result.as_ref()); + failed + } + + /// The `(class_path, archetype_path)` an actor channel is labelled with, + /// shared by open and close so the two cannot drift into a join key that + /// silently does not join. + fn actor_paths(&self, archetype: Option) -> (Option, Option) { + let (outer, arch_path) = self.archetype_paths(archetype); + let combined = combined_candidate(outer.as_deref(), arch_path.as_deref()); + (combined.or(outer), arch_path) + } + + /// Capture one `PlayerIdentity` field of the current BombPlayerState actor. + fn record_player_identity( + &mut self, + field_name: Option<&str>, + subject: Option<&str>, + character: Option, + ) { + // Through `canonical_group`: Swiftplay replicates these fields under + // `Swiftplay_EoRCredits_PlayerState_C`. + if vrf_decode::canonical_group(&self.current_group_path) != BOMB_PLAYER_STATE { + return; + } + let Some(name) = field_name else { + return; + }; + let entry = self + .channel_state + .players + .entry(self.current_actor_guid) + .or_default(); + match name { + "Subject" => { + if let Some(s) = subject { + entry.subject = Some(s.to_owned()); + } + } + // A disconnect replicates it again as 0, which is not a NetGUID. + "SpawnedCharacter" => { + if let Some(c) = character.filter(|c| *c != 0).map(|c| c as u32) { + entry.character_net_guids.retain(|&g| g != c); + entry.character_net_guids.push(c); + } + } + // PossessedCharacter can be a camera, drone or other ability pawn. + // It must never replace the body used to identify effect targets. + _ => {} + } + } + + /// Walk an unresolved `AbilitiesAndBuffsComponent` payload's ClassNetCache + /// stream at [`ABILITIES_AND_BUFFS_FC`] and emit one `_cnc_h{N}` row per RPC + /// beside the preservation row. The framing can carry custom-delta + /// properties as well as RPCs, so this legacy name does not establish an + /// ability cast; the inner bits are kept, not typed. + fn emit_brute_forced_cnc_rpcs(&mut self, payload: &[u8], bit_count: u32) { + if !self + .current_group_path + .contains(ABILITIES_AND_BUFFS_COMPONENT) + { + return; + } + + self.stats.cnc_bruteforce_payloads_attempted += 1; + let Some(rpcs) = decode_cnc_payload(payload, bit_count, ABILITIES_AND_BUFFS_FC) else { + // The preservation row already holds the payload whole; what the + // caller must not lose is that the walk failed. + self.stats.cnc_bruteforce_payloads_unwalked += 1; + return; + }; + + for rpc in &rpcs { + // A zero-bit RPC keeps an empty blob here, where `copy_raw_bits` + // gives null; none has been observed, and switching changes output. + let raw_bits = cnc_body(payload, bit_count, rpc).and_then(|body| { + if rpc.payload_bits == 0 { + return Some(SmallVec::new()); + } + copy_raw_bits(body, rpc.payload_bits) + }); + + let field_name = self.channel_state.names.intern_fmt(|out| { + put(out, format_args!("_cnc_h{}", rpc.handle)); + }); + + self.push_field(FieldValues { + handle: rpc.handle, + field_name: Some(field_name), + bit_count: rpc.payload_bits, + raw_bits, + ..FieldValues::default() + }); + self.stats.cnc_rpcs_emitted += 1; + } + } +} + +/// Three optional spawn components from one optional vector: all or none. +fn transpose(v: Option<[f32; 3]>) -> [Option; 3] { + v.map_or([None; 3], |v| v.map(Some)) +} + +/// One walked CNC RPC's body: a reader over its `payload_bits` bits. +fn cnc_body<'p>(payload: &'p [u8], bit_count: u32, rpc: &CncRpc) -> Option> { + let mut reader = BitReader::with_bit_len(payload, u64::from(bit_count)).ok()?; + reader.skip_bits(rpc.payload_offset).ok()?; + reader.sub_reader(u64::from(rpc.payload_bits)).ok() +} + +impl ReplicationSink for ExportSink<'_> { + fn on_rejected_partial(&mut self, p: RejectedPartialFragment<'_>) { + let reason = match p.reason { + PartialPayloadReason::MissingInitial => "missing_initial", + PartialPayloadReason::OverlappingInitial => "overlapping_initial", + PartialPayloadReason::MismatchedContinuation => "mismatched_continuation", + PartialPayloadReason::NonByteAlignedFragment => "non_byte_aligned_fragment", + PartialPayloadReason::ActiveStateLimit => "active_state_limit", + PartialPayloadReason::BufferedBitsLimit => "buffered_bits_limit", + PartialPayloadReason::AllocationFailure => "allocation_failure", + PartialPayloadReason::ChannelStateLimit => "channel_state_limit", + PartialPayloadReason::ChannelClosed => "channel_closed", + PartialPayloadReason::EndOfStream => "end_of_stream", + }; + let mut raw_bits = p.payload.to_vec(); + if p.bit_count % 8 != 0 { + if let Some(last) = raw_bits.last_mut() { + *last &= (1u8 << (p.bit_count % 8)) - 1; + } + } + let h = p.header; + self.records.partials.push(PartialRecord { + source: "", + checkpoint_id: None, + payload_kind: p.payload_kind, + reason, + source_packet_id: h.packet_id, + source_payload_bit_offset: h.payload_bit_offset, + rejection_packet_id: p.rejection_packet_id, + channel_index: h.ch_index, + channel_sequence: h.ch_sequence, + open: h.b_open, + close: h.b_close, + dormant: h.b_dormant, + replication_paused: h.b_is_replication_paused, + reliable: h.b_reliable, + partial: h.b_partial, + partial_initial: h.b_partial_initial, + partial_final: h.b_partial_final, + has_package_map_exports: h.b_has_package_map_exports, + has_must_be_mapped_guids: h.b_has_must_be_mapped_guids, + close_reason: h.close_reason as u8, + source_payload_bit_count: h.payload_bit_count, + bit_count: p.bit_count as u64, + raw_bits, + }); + } + fn on_actor_open(&mut self, state: &ActorChannelState) { + self.stats.actor_opens += 1; + // Stamped with its actor, because channel numbers are recycled; see + // `paths::ChannelArchetype`. + if state.archetype_net_guid.is_valid() { + set_channel_archetype( + self.channel_state, + state.channel_index, + state.actor_net_guid, + state.archetype_net_guid, + ); + } + + // A static actor has no archetype, so its class and archetype paths + // stay null: its GUID path is the level's instance name + // (`Ascent_C_0`), not a class. + let (class_path, archetype_path) = self.actor_paths(Some(state.archetype_net_guid)); + + let xyz = |v: Option| v.map(|v| [v.x, v.y, v.z].map(|c| c as f32)); + let [spawn_x, spawn_y, spawn_z] = transpose(xyz(state.spawn_location)); + let [spawn_vx, spawn_vy, spawn_vz] = transpose(xyz(state.spawn_velocity)); + let rotation = state.spawn_rotation.map(|r| [r.pitch, r.yaw, r.roll]); + let [spawn_pitch, spawn_yaw, spawn_roll] = transpose(rotation); + + self.records.actors.push(ActorRecord { + time_ms: self.time_ms, + packet_id: self.packet_id, + channel_index: state.channel_index, + actor_net_guid: state.actor_net_guid.0, + event: "open", + class_path, + archetype_path, + spawn_x, + spawn_y, + spawn_z, + spawn_pitch, + spawn_yaw, + spawn_roll, + spawn_vx, + spawn_vy, + spawn_vz, + }); + } + + fn on_actor_close(&mut self, channel_index: u32, actor_net_guid: NetworkGuid, dormant: bool) { + self.stats.actor_closes += 1; + + // As in `on_actor_open`, a static actor gets no class_path, so its open + // and close rows agree. + let archetype = channel_archetype(self.channel_state, channel_index, actor_net_guid); + let (class_path, archetype_path) = self.actor_paths(archetype); + + // `ChannelCloseReason::Dormancy` (vrf-net's `b_dormant`) stops + // replication of a live actor; every other reason is the actor going + // away. Both emit a row; only the label differs. + let event = if dormant { "dormant" } else { "close" }; + + self.records.actors.push(ActorRecord { + time_ms: self.time_ms, + packet_id: self.packet_id, + channel_index, + actor_net_guid: actor_net_guid.0, + event, + class_path, + archetype_path, + ..ActorRecord::default() + }); + if !dormant { + retire_channel_archetype(self.channel_state, channel_index); + } + } + + fn on_content_block( + &mut self, + channel_index: u32, + actor_net_guid: NetworkGuid, + header: &ContentBlockHeader, + ) -> u32 { + self.current_channel = channel_index; + self.current_actor_guid = actor_net_guid.0; + // An actor block carries no subobject GUID; a subobject block's GUID + // tells a character's inventory slots apart (merged, a player seems to + // hold one item). A GUID of 0 stays `Some(0)`: downstream `None` means + // "actor block", and the block would collapse onto the actor. + self.current_object_guid = if header.is_actor { + None + } else { + Some(header.object_net_guid.0) + }; + self.current_is_abilities_and_buffs = !header.is_actor + && self.cache.get_path_by_guid(header.object_net_guid.0) + == Some(ABILITIES_AND_BUFFS_COMPONENT); + self.stats.content_blocks += 1; + let function_count = self.resolve_block(channel_index, actor_net_guid, header); + self.record_checkpoint_block(channel_index, actor_net_guid, header, function_count, true); + function_count + } + + fn on_rep_layout_tail( + &mut self, + _actor_net_guid: NetworkGuid, + bit_count: u32, + reader: BitReader<'_>, + ) -> RepLayoutTailOutcome { + let Some(raw_tail) = copy_raw_bits(reader, bit_count) else { + return RepLayoutTailOutcome::Unpreserved { + cause: StreamFailureCause::ReadError, + }; + }; + + if self.current_is_abilities_and_buffs { + // 34 is the minimum of the measured 34..=65 band, not a declared + // count: safe only with the direct pre-remap component identity + // above and the strict checks below (one handle-1 RPC, exact end, + // set body flag). + let rpcs = decode_cnc_payload(&raw_tail, bit_count, ABILITIES_AND_BUFFS_FC); + if let Some([rpc]) = rpcs.as_deref() { + let body = cnc_body(&raw_tail, bit_count, rpc) + .filter(|body| rpc.handle == 1 && body.clone().read_bit().is_ok_and(|bit| bit)); + if let Some(raw_body) = body.and_then(|body| copy_raw_bits(body, rpc.payload_bits)) + { + let field_name = self.channel_state.names.intern(CHAINED_CNC_H1_FIELD_NAME); + self.push_field(FieldValues { + handle: rpc.handle, + field_name: Some(field_name), + bit_count: rpc.payload_bits, + raw_bits: Some(raw_body), + ..FieldValues::default() + }); + self.stats.rpcs_emitted += 1; + self.stats.cnc_rpcs_emitted += 1; + self.stats.rep_layout_cnc_tails_decoded += 1; + return RepLayoutTailOutcome::Decoded { rpc_count: 1 }; + } + } + } + + let field_name = self + .channel_state + .names + .intern(UNPARSED_REP_LAYOUT_TAIL_FIELD_NAME); + self.push_field(FieldValues { + field_name: Some(field_name), + bit_count, + raw_bits: Some(raw_tail), + ..FieldValues::default() + }); + self.stats.rep_layout_cnc_tails_preserved += 1; + RepLayoutTailOutcome::Preserved { + cause: StreamFailureCause::UnverifiedRepLayoutTail, + } + } + + fn on_rep_layout_tail_failure_payload( + &mut self, + failure: StreamFailure, + reader: BitReader<'_>, + ) { + let Some(raw) = copy_raw_bits(reader, failure.bit_count) else { + return; + }; + if let Some(failures) = self.channel_state.failures.as_mut() { + failures.note_payload(&failure, Arc::clone(&self.current_group_path), &raw); + } + } + + fn on_deleted_block( + &mut self, + channel_index: u32, + actor_net_guid: NetworkGuid, + header: &ContentBlockHeader, + ) { + self.stats.content_blocks += 1; + self.record_checkpoint_block(channel_index, actor_net_guid, header, 0, false); + } + + fn on_unresolved_class_net_cache_payload(&mut self, failure: StreamFailure, payload: &[u8]) { + if let Some(failures) = self.channel_state.failures.as_mut() { + failures.note_payload(&failure, Arc::clone(&self.current_group_path), payload); + } + + let field_name = self + .channel_state + .names + .intern(UNRESOLVED_CLASS_NET_CACHE_PAYLOAD_FIELD_NAME); + self.push_field(FieldValues { + handle: u32::MAX, + field_name: Some(field_name), + bit_count: failure.bit_count, + raw_bits: Some(SmallVec::from_slice(payload)), + ..FieldValues::default() + }); + + // Additive: the fc=34 walk adds RPC rows; the row above stays. + self.emit_brute_forced_cnc_rpcs(payload, failure.bit_count); + } + + /// Sample the payload of a block whose inner stream failed to walk; framing + /// calls it after that block's `on_stream_failure`. + fn on_stream_failure_payload(&mut self, failure: StreamFailure, payload: &[u8]) { + if let Some(failures) = self.channel_state.failures.as_mut() { + failures.note_payload(&failure, Arc::clone(&self.current_group_path), payload); + } + } + + fn wants_stream_failure_details(&self) -> bool { + self.channel_state.failures.is_some() + } + + /// Attach the resolved group path to a stream failure: the only place both + /// the bit offsets and the class to investigate are known. `function_count` + /// 0 names an unresolved group; 1 and 2 both read at the parser's minimum + /// of 2, so this line cannot tell them apart. With diagnostics on, the + /// failure also goes to the bounded + /// [`FailureAggregate`](super::failure_stats::FailureAggregate). + fn on_stream_failure(&mut self, failure: StreamFailure) { + let line = format!( + "{:?} actor={} bits={} function_count={} consumed={} skipped={} group={}", + failure.kind, + failure.actor_net_guid.0, + failure.bit_count, + failure.function_count, + failure.consumed_bits, + failure.remaining_bits, + self.current_group_path, + ); + self.channel_state.push_stream_failure(line); + if let Some(failures) = self.channel_state.failures.as_mut() { + failures.note_failure(&failure, Arc::clone(&self.current_group_path)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::sink::test_fixtures::{Rig, actor_block, channel_open, subobject_block}; + use crate::vendor::sink::{ExportStats, RecordBuffers}; + use vrf_testkit::{BitWrite, pack, unpack}; + + /// The subobject GUID a block records for its fields: `None` for an actor + /// block whatever the header says, `Some(0)` for a subobject whose GUID is + /// the invalid 0 (see `on_content_block` and `FieldRecord::object_net_guid`). + #[test] + fn only_subobject_blocks_record_an_object_guid_zero_included() { + for (is_actor, guid, want) in [ + (true, 0, None), + (true, 99, None), + (false, 0, Some(0)), + (false, 99, Some(99)), + ] { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + let header = ContentBlockHeader { + is_actor, + ..subobject_block(guid, true) + }; + sink.on_content_block(7, NetworkGuid(1234), &header); + assert_eq!(sink.current_object_guid, want, "{is_actor} {guid}"); + } + } + + fn rejected_partial_row( + reason: PartialPayloadReason, + bit_count: usize, + payload: &[u8], + ) -> PartialRecord { + let mut rig = Rig::default(); + { + let mut sink = rig.sink(); + let header = vrf_net::bunch::RawBunchHeader { + packet_id: 41, + ch_index: 7, + ch_sequence: 9, + b_reliable: true, + b_partial: true, + b_partial_initial: true, + payload_bit_count: 13, + payload_bit_offset: 57, + ..Default::default() + }; + sink.on_rejected_partial(RejectedPartialFragment { + header: &header, + payload_kind: "accumulated_payload", + reason, + bit_count, + payload, + rejection_packet_id: Some(44), + }); + } + assert_eq!(rig.records.partials.len(), 1); + rig.records.partials.remove(0) + } + + /// A rejected partial keeps the bits it declares and no more: bits past + /// `bit_count` in the last byte are staging residue, not payload. + #[test] + fn a_rejected_partial_row_masks_its_last_byte_and_keeps_its_header() { + let row = rejected_partial_row(PartialPayloadReason::ChannelClosed, 13, &[0xFF, 0xFF]); + assert_eq!( + row.raw_bits, + vec![0xFF, 0x1F], + "bits past bit_count are masked" + ); + assert_eq!( + (row.reason, row.payload_kind, row.bit_count), + ("channel_closed", "accumulated_payload", 13) + ); + assert_eq!( + ( + row.source_packet_id, + row.rejection_packet_id, + row.channel_index, + row.channel_sequence + ), + (41, Some(44), 7, 9) + ); + assert_eq!( + (row.source_payload_bit_offset, row.source_payload_bit_count), + (57, 13) + ); + assert!(row.reliable && row.partial && row.partial_initial && !row.partial_final); + + let aligned = rejected_partial_row(PartialPayloadReason::EndOfStream, 16, &[0xFF, 0xFF]); + assert_eq!( + aligned.raw_bits, + vec![0xFF, 0xFF], + "a whole last byte is kept" + ); + } + + /// Every rejection cause reaches the table under its own name, the + /// variant's in snake case. A relabelled cause is a plausible wrong value: + /// the row still looks well-formed. + #[test] + fn every_rejected_partial_reason_has_a_distinct_name() { + use PartialPayloadReason::*; + for reason in [ + MissingInitial, + OverlappingInitial, + MismatchedContinuation, + NonByteAlignedFragment, + ActiveStateLimit, + BufferedBitsLimit, + AllocationFailure, + ChannelStateLimit, + ChannelClosed, + EndOfStream, + ] { + let mut snake = String::new(); + for (i, c) in format!("{reason:?}").char_indices() { + if c.is_ascii_uppercase() && i > 0 { + snake.push('_'); + } + snake.push(c.to_ascii_lowercase()); + } + assert_eq!(rejected_partial_row(reason, 8, &[0]).reason, snake); + } + } + #[test] + fn on_field_keeps_exact_parent_raw_bits_for_unknown_and_typed_failures() { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + let payload = [0b1110_1101]; + + sink.on_field( + 999, + 5, + BitReader::with_bit_len(&payload, 5).expect("bounded field reader"), + ); + + sink.set_current_group_path(Arc::from( + "/Script/ShooterGame.DamageableComponent:MulticastNotifyDamage_Base", + )); + sink.on_field( + 0, + 5, + BitReader::with_bit_len(&payload, 5).expect("bounded field reader"), + ); + + assert_eq!(sink.records.fields.len(), 2); + for row in &sink.records.fields { + assert_eq!(row.bit_count, 5); + assert_eq!(row.raw_bits.as_deref(), Some(&[0b0000_1101][..])); + } + assert_eq!(sink.records.fields[0].field_name, None); + assert_eq!( + sink.records.fields[1].field_name.as_deref(), + Some("DamageTaken") + ); + assert!(sink.records.fields[1].value_f64.is_none()); + assert_eq!(sink.stats.overlay.decoded_err, 1); + } + + /// The failure framing reports for an unresolved ClassNetCache block of + /// `bit_count` bits: nothing consumed, the whole payload preserved. + fn unresolved_failure(actor: u32, bit_count: u32) -> StreamFailure { + StreamFailure { + kind: vrf_net::pipeline::StreamKind::Rpc, + actor_net_guid: NetworkGuid(actor), + bit_count, + function_count: 0, + consumed_bits: 0, + remaining_bits: u64::from(bit_count), + cause: StreamFailureCause::UnresolvedFunctionCount, + record_handle: None, + record_offset: Some(0), + payload_preserved: true, + } + } + + /// A 200-bit RepLayout stream on actor 7 abandoned at `consumed`, in + /// handle 3's record. + fn abandoned_tail(consumed: u64) -> StreamFailure { + StreamFailure { + kind: vrf_net::pipeline::StreamKind::RepLayout, + actor_net_guid: NetworkGuid(7), + bit_count: 200, + function_count: 0, + consumed_bits: consumed, + remaining_bits: 200 - consumed, + cause: StreamFailureCause::AbandonedTail, + record_handle: Some(3), + record_offset: Some(consumed), + payload_preserved: false, + } + } + + fn assert_untyped(row: &vrf_export::FieldRecord) { + assert!(row.compatible_checksum.is_none()); + assert!(row.value_i64.is_none() && row.value_f64.is_none()); + assert!(row.value_bool.is_none() && row.value_str.is_none()); + } + + /// A sink inside an `AbilitiesAndBuffsComponent` block (object 144, + /// actor 89, channel 3). + fn abilities_block(rig: &mut Rig, has_rep_layout: bool) -> ExportSink<'_> { + rig.cache + .set_net_guid_path(144, ABILITIES_AND_BUFFS_COMPONENT, None); + let mut sink = rig.sink(); + sink.on_content_block(3, NetworkGuid(89), &subobject_block(144, has_rep_layout)); + sink + } + + /// A whole unresolved block is one preservation row, not an RPC or a set + /// of invented fields. The reserved field name is its sole discriminator. + #[test] + fn unresolved_class_net_cache_payload_emits_one_distinguished_row() { + let mut rig = Rig::default(); + let mut sink = abilities_block(&mut rig, false); + assert_eq!( + sink.current_function_count_source, + "unresolved_class_net_cache" + ); + sink.time_ms = 1234; + sink.packet_id = 56; + + let failure = unresolved_failure(89, 7); + sink.on_unresolved_class_net_cache_payload(failure, &[0x66]); + + assert_eq!(sink.records.fields.len(), 1); + let row = &sink.records.fields[0]; + assert_eq!(row.time_ms, 1234); + assert_eq!(row.packet_id, 56); + assert_eq!(row.channel_index, 3); + assert_eq!(row.actor_net_guid, 89); + assert_eq!(row.object_net_guid, Some(144)); + assert_eq!(&*row.group_path, "AbilitiesAndBuffsComponent"); + assert_eq!(row.handle, u32::MAX); + assert_eq!( + row.field_name.as_deref(), + Some(UNRESOLVED_CLASS_NET_CACHE_PAYLOAD_FIELD_NAME) + ); + assert_eq!(row.bit_count, 7); + assert_eq!(row.raw_bits.as_deref(), Some(&[0x66][..])); + assert_untyped(row); + assert_eq!(sink.stats.fields_emitted, 1); + assert_eq!(sink.stats.rpcs_emitted, 0); + assert_eq!(sink.stats.overlay.decoded_ok, 0); + assert_eq!(sink.stats.overlay.decoded_err, 0); + assert_eq!(sink.stats.overlay.raw_or_skip, 0); + assert_eq!(sink.stats.overlay.not_in_table, 0); + assert_eq!(sink.stats.overlay.no_field_name, 0); + } + + fn one_h1_cnc_tail(body: &[bool]) -> Vec { + let mut bits = Vec::new(); + bits.serialized_int(1, ABILITIES_AND_BUFFS_FC); + bits.int_packed(body.len() as u32); + bits.extend_from_slice(body); + bits + } + + #[test] + fn verified_abilities_tail_emits_one_raw_structural_h1_row() { + let body = [true, false, true, false, true, false, true, false, true]; + let tail = one_h1_cnc_tail(&body); + let tail_bytes = pack(&tail); + let mut rig = Rig::default(); + let mut sink = abilities_block(&mut rig, true); + + let outcome = sink.on_rep_layout_tail( + NetworkGuid(89), + tail.len() as u32, + BitReader::with_bit_len(&tail_bytes, tail.len() as u64).unwrap(), + ); + + assert_eq!(outcome, RepLayoutTailOutcome::Decoded { rpc_count: 1 }); + assert_eq!(sink.records.fields.len(), 1); + let row = &sink.records.fields[0]; + assert_eq!(row.handle, 1); + assert_eq!(row.field_name.as_deref(), Some(CHAINED_CNC_H1_FIELD_NAME)); + assert_eq!(row.bit_count, body.len() as u32); + assert_eq!(row.raw_bits.as_deref(), Some(pack(&body).as_slice())); + assert_untyped(row); + assert_eq!(sink.stats.rep_layout_cnc_tails_decoded, 1); + assert_eq!(sink.stats.rep_layout_cnc_tails_preserved, 0); + } + + #[test] + fn matching_tail_shape_without_raw_component_provenance_stays_whole_and_raw() { + let body = [true, false, true, false, true, false, true, false, true]; + let tail = one_h1_cnc_tail(&body); + let tail_bytes = pack(&tail); + let mut rig = Rig::default(); + rig.cache + .set_net_guid_path(145, ABILITIES_AND_BUFFS_COMPONENT, None); + let mut sink = rig.sink(); + let known_header = subobject_block(145, true); + sink.on_content_block(3, NetworkGuid(89), &known_header); + assert!(sink.current_is_abilities_and_buffs); + + let header = subobject_block(144, true); + sink.on_content_block(3, NetworkGuid(89), &header); + assert!( + !sink.current_is_abilities_and_buffs, + "a following unresolved block must clear prior provenance" + ); + + let outcome = sink.on_rep_layout_tail( + NetworkGuid(89), + tail.len() as u32, + BitReader::with_bit_len(&tail_bytes, tail.len() as u64).unwrap(), + ); + + assert_eq!( + outcome, + RepLayoutTailOutcome::Preserved { + cause: StreamFailureCause::UnverifiedRepLayoutTail, + } + ); + assert_eq!(sink.records.fields.len(), 1); + let row = &sink.records.fields[0]; + assert_eq!( + row.field_name.as_deref(), + Some(UNPARSED_REP_LAYOUT_TAIL_FIELD_NAME) + ); + assert_eq!(row.bit_count, tail.len() as u32); + assert_eq!(row.raw_bits.as_deref(), Some(tail_bytes.as_slice())); + assert_untyped(row); + assert_eq!(sink.stats.rep_layout_cnc_tails_decoded, 0); + assert_eq!(sink.stats.rep_layout_cnc_tails_preserved, 1); + } + + #[test] + fn verified_component_preserves_exact_but_unverified_tail_shapes_whole() { + let first = one_h1_cnc_tail(&[true, false, true]); + let mut two_rpcs = first.clone(); + two_rpcs.extend(one_h1_cnc_tail(&[true, true, false])); + let false_flag = one_h1_cnc_tail(&[false, true, true]); + + let mut rig = Rig::default(); + let mut sink = abilities_block(&mut rig, true); + + for tail in [&two_rpcs, &false_flag] { + let raw = pack(tail); + let outcome = sink.on_rep_layout_tail( + NetworkGuid(89), + tail.len() as u32, + BitReader::with_bit_len(&raw, tail.len() as u64).unwrap(), + ); + assert_eq!( + outcome, + RepLayoutTailOutcome::Preserved { + cause: StreamFailureCause::UnverifiedRepLayoutTail, + } + ); + let row = sink.records.fields.last().unwrap(); + assert_eq!( + row.field_name.as_deref(), + Some(UNPARSED_REP_LAYOUT_TAIL_FIELD_NAME) + ); + assert_eq!(row.bit_count, tail.len() as u32); + assert_eq!(row.raw_bits.as_deref(), Some(raw.as_slice())); + } + assert_eq!(sink.stats.rep_layout_cnc_tails_decoded, 0); + assert_eq!(sink.stats.rep_layout_cnc_tails_preserved, 2); + } + + /// A first parameter declaring more bits than remain bumps `truncated_rpcs`: + /// no row lands, so the counter alone tells this from a payload with no + /// parameters. `rpc_param_walks` counts the walks that could set it, and + /// not a payload with no function name, which never starts one. + #[test] + fn a_truncated_rpc_payload_increments_truncated_rpcs() { + let mut bits = Vec::new(); + bits.push(false); // property checksum + bits.int_packed(1); // encodedHandle = 1 -> handle 0 + bits.int_packed(100); // payload_bits = 100 (exceeds remaining) + // No payload data follows: the walker breaks here. + let data = pack(&bits); + let reader = BitReader::with_bit_len(&data, bits.len() as u64).unwrap(); + + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + assert!(!sink.try_parse_rpc_params(7, reader.clone(), None)); + assert_eq!(sink.stats.rpc_param_walks, 0); + let emitted = sink.try_parse_rpc_params(7, reader, Some("SomeFunction")); + assert!(!emitted, "no parameter rows are emitted before the break"); + assert_eq!(sink.stats.truncated_rpcs, 1); + assert_eq!(sink.stats.rpc_param_walks, 1); + } + + fn targeting_rpc( + group: &str, + parent_handle: u32, + parent_name: &str, + parent_checksum: u32, + child_name: &str, + child_checksum: u32, + array_bits: &[bool], + ) -> (RecordBuffers, ExportStats) { + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new(group.into(), 7, 3)) + .unwrap(); + for (handle, name, checksum) in [ + (parent_handle, parent_name, parent_checksum), + (1, child_name, child_checksum), + ] { + assert!(rig.cache.set_field_on_group( + 7, + vrf_schema::NetFieldExport { + handle, + compatible_checksum: checksum, + name: name.into(), + }, + )); + } + let mut rpc_bits = vec![false]; + rpc_bits.int_packed(parent_handle + 1); + rpc_bits.int_packed(array_bits.len() as u32); + rpc_bits.extend_from_slice(array_bits); + rpc_bits.int_packed(0); + let raw = pack(&rpc_bits); + let mut sink = rig.sink(); + assert!(sink.try_parse_rpc_params( + 3, + BitReader::with_bit_len(&raw, rpc_bits.len() as u64).unwrap(), + Some("MulticastRespondToValidMapClick"), + )); + let stats = sink.stats.clone(); + (rig.records, stats) + } + + const TARGETING_GROUP: &str = + "/Script/ShooterGame.MapTargetingStateComponent:MulticastRespondToValidMapClick"; + + /// `targeting_rpc` with the measured parent and child declarations. + fn valid_targeting(array_bits: &[bool]) -> (RecordBuffers, ExportStats) { + targeting_rpc( + TARGETING_GROUP, + 0, + "WorldLocation", + 2052180909, + "WorldLocation", + 3965480401, + array_bits, + ) + } + + fn append_world_location(bits: &mut Vec, values: [f64; 3]) { + bits.int_packed(2); + bits.int_packed(192); + for value in values { + bits.extend(unpack(&value.to_le_bytes())); + } + } + + fn world_locations(values: &[[f64; 3]]) -> Vec { + let mut bits = Vec::new(); + bits.int_packed(values.len() as u32); + for (index, values) in values.iter().copied().enumerate() { + bits.int_packed(index as u32 + 1); + append_world_location(&mut bits, values); + bits.int_packed(0); + } + bits.int_packed(0); + bits + } + + fn one_world_location(values: [f64; 3]) -> Vec { + world_locations(&[values]) + } + + #[test] + fn guarded_targeting_array_emits_vector_child_and_raw_parent() { + let array = one_world_location([12.5, -9.25, 3.0]); + let (records, stats) = valid_targeting(&array); + assert_eq!(records.fields.len(), 2); + assert_eq!( + records.fields[0].field_name.as_deref(), + Some("MulticastRespondToValidMapClick.WorldLocation[0].WorldLocation") + ); + assert_eq!(records.fields[0].bit_count, 192); + assert_eq!(records.fields[0].handle, 3); + assert_eq!( + records.fields[0].value_str.as_deref(), + Some("(12.5,-9.25,3)") + ); + let expected_raw: Vec = [12.5f64, -9.25, 3.0] + .into_iter() + .flat_map(f64::to_le_bytes) + .collect(); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(expected_raw.as_slice()) + ); + assert_eq!( + records.fields[1].raw_bits.as_deref(), + Some(pack(&array).as_slice()) + ); + assert_eq!(stats.targeting_world_locations_decoded, 1); + assert_eq!(stats.fields_emitted, 2, "the child row is counted"); + + let signed_zero = one_world_location([-0.0, 0.0, -0.0]); + let (records, stats) = valid_targeting(&signed_zero); + let expected_raw: Vec = [-0.0f64, 0.0, -0.0] + .into_iter() + .flat_map(f64::to_le_bytes) + .collect(); + assert_eq!( + records.fields[0].raw_bits.as_deref(), + Some(expected_raw.as_slice()) + ); + assert_eq!(records.fields[0].handle, 3); + assert_eq!(stats.targeting_world_locations_decoded, 1); + } + + #[test] + fn targeting_array_requires_exact_child_declaration_and_complete_grammar() { + let array = one_world_location([1.0, 2.0, 3.0]); + let empty = world_locations(&[]); + let (records, stats) = valid_targeting(&empty); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.targeting_world_locations_decoded, 0); + assert_eq!(stats.array_leaf_decode_errors, 0); + for (name, checksum) in [("Other", 3965480401), ("WorldLocation", 7)] { + let (records, stats) = targeting_rpc( + TARGETING_GROUP, + 0, + "WorldLocation", + 2052180909, + name, + checksum, + &array, + ); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.targeting_world_locations_decoded, 0); + } + for (candidate_group, handle, name, checksum) in [ + (TARGETING_GROUP, 0, "WorldLocation", 7), + (TARGETING_GROUP, 0, "Other", 2052180909), + (TARGETING_GROUP, 2, "WorldLocation", 2052180909), + ( + "/Script/ShooterGame.Other:MulticastRespondToValidMapClick", + 0, + "WorldLocation", + 2052180909, + ), + ] { + let (records, stats) = targeting_rpc( + candidate_group, + handle, + name, + checksum, + "WorldLocation", + 3965480401, + &array, + ); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.targeting_world_locations_decoded, 0); + } + let truncated = &array[..array.len() - 8]; + let (records, stats) = valid_targeting(truncated); + assert_eq!(records.fields.len(), 1); + assert_eq!(stats.targeting_world_locations_decoded, 0); + assert!( + stats.array.errors > 0 + || stats.array.unconsumed_root_bits > 0 + || stats.array.implicit_terminations > 0 + ); + + let mut residual = array.clone(); + residual.push(true); + let (records, stats) = valid_targeting(&residual); + assert_eq!(records.fields.len(), 1); + assert!(stats.array.unconsumed_root_bits > 0); + + let wrong_handle = { + let mut bits = Vec::new(); + for value in [1, 1, 3, 192] { + bits.int_packed(value); + } + bits.extend(std::iter::repeat_n(false, 192)); + bits.int_packed(0); + bits.int_packed(0); + bits + }; + let (records, stats) = valid_targeting(&wrong_handle); + assert_eq!(records.fields.len(), 1); + assert!(stats.array_leaf_decode_errors > 0); + + let wrong_width = { + let mut bits = Vec::new(); + for value in [1, 1, 2, 191] { + bits.int_packed(value); + } + bits.extend(std::iter::repeat_n(false, 191)); + bits.int_packed(0); + bits.int_packed(0); + bits + }; + let (records, stats) = valid_targeting(&wrong_width); + assert_eq!(records.fields.len(), 1); + assert!(stats.array_leaf_decode_errors > 0); + + let nonfinite = one_world_location([f64::NAN, 0.0, -0.0]); + let (records, stats) = valid_targeting(&nonfinite); + assert_eq!(records.fields.len(), 1); + assert!(stats.array_leaf_decode_errors > 0); + + let mut duplicate_member = Vec::new(); + duplicate_member.int_packed(1); + duplicate_member.int_packed(1); + append_world_location(&mut duplicate_member, [1.0, 2.0, 3.0]); + append_world_location(&mut duplicate_member, [4.0, 5.0, 6.0]); + duplicate_member.int_packed(0); + duplicate_member.int_packed(0); + let (records, stats) = valid_targeting(&duplicate_member); + assert_eq!(records.fields.len(), 1); + assert!(stats.array_leaf_decode_errors > 0); + + let mixed = world_locations(&[[1.0, 2.0, 3.0], [f64::NAN, 5.0, 6.0]]); + let (records, stats) = valid_targeting(&mixed); + assert_eq!( + records.fields.len(), + 1, + "children are emitted transactionally" + ); + assert!(stats.array_leaf_decode_errors > 0); + } + + /// Build an RPC payload of one parameter, the zero-handle terminator, and + /// `suffix_bits` bits of whatever follows it. + fn rpc_payload_with_suffix(suffix_bits: usize) -> Vec { + let mut bits = Vec::new(); + bits.push(false); // property checksum + bits.int_packed(1); // encodedHandle = 1 -> handle 0 + bits.int_packed(8); // payload_bits = 8 + bits.extend(std::iter::repeat_n(false, 8)); // payload + bits.int_packed(0); // terminator handle + bits.extend(std::iter::repeat_n(true, suffix_bits)); + bits + } + + /// Bits after the zero-handle terminator are counted, not rejected, and the + /// rows already parsed stay (see `ExportStats::rpc_suffix_bits_dropped`). + #[test] + fn bits_after_the_rpc_terminator_are_counted_not_discarded() { + let bits = rpc_payload_with_suffix(16); + let data = pack(&bits); + let reader = BitReader::with_bit_len(&data, bits.len() as u64).unwrap(); + + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + let emitted = sink.try_parse_rpc_params(7, reader, Some("SomeFunction")); + assert!(emitted, "the parameter that did parse is still emitted"); + assert_eq!( + sink.stats.rpc_suffix_bits_dropped, 16, + "the bits after the terminator must reach a counter" + ); + // Not a truncation: the walk ended where the wire told it to. + assert_eq!(sink.stats.truncated_rpcs, 0); + assert_eq!( + rig.records.fields.len(), + 2, + "parameter plus whole raw fallback" + ); + let fallback = rig.records.fields.last().unwrap(); + assert_eq!(fallback.bit_count, bits.len() as u32); + assert_eq!(fallback.raw_bits.as_deref(), Some(data.as_slice())); + } + + #[test] + fn a_partially_parsed_truncated_rpc_retains_the_whole_payload() { + let mut bits = Vec::new(); + bits.push(false); // property checksum + bits.int_packed(1); + bits.int_packed(8); + bits.extend(std::iter::repeat_n(false, 8)); // one complete parameter + bits.int_packed(2); + bits.int_packed(100); // second parameter overruns + bits.extend(std::iter::repeat_n(true, 8)); + let data = pack(&bits); + let reader = BitReader::with_bit_len(&data, bits.len() as u64).unwrap(); + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + assert!(sink.try_parse_rpc_params(7, reader, Some("SomeFunction"))); + assert_eq!(sink.stats.truncated_rpcs, 1); + assert_eq!( + rig.records.fields.len(), + 2, + "parameter plus whole raw fallback" + ); + let fallback = rig.records.fields.last().unwrap(); + assert_eq!(fallback.bit_count, bits.len() as u32); + assert_eq!(fallback.raw_bits.as_deref(), Some(data.as_slice())); + } + + #[test] + fn a_failed_movement_decode_retains_the_whole_rpc_payload() { + let mut update = Vec::new(); + update.int_packed(3); // shooter GUID handle 2 + update.int_packed(32); + for bit in 0..32 { + update.push((4321u32 & (1 << bit)) != 0); + } + update.int_packed(4); // component stream handle 3 + update.int_packed(8); + update.extend(std::iter::repeat_n(false, 8)); // short u16 header + update.int_packed(0); + + let mut array = Vec::new(); + array.int_packed(1); + array.int_packed(1); + array.extend(update); + array.int_packed(0); + + let mut bits = vec![false]; // top-level ignored bit + bits.int_packed(2); // updates-array handle 1 + bits.int_packed(array.len() as u32); + bits.extend(array); + bits.int_packed(0); + let data = pack(&bits); + + let path = "/Script/Test.Movement_ClassNetCache"; + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new(path.into(), 7, 1)) + .unwrap(); + assert!(rig.cache.set_field_on_group( + 7, + vrf_schema::NetFieldExport { + handle: 0, + compatible_checksum: 0, + name: MOVEMENT_RPC.into(), + }, + )); + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from(path)); + let reader = BitReader::with_bit_len(&data, bits.len() as u64).unwrap(); + + sink.on_rpc(0, bits.len() as u32, reader); + + assert_eq!(sink.stats.movement_rpc_errors, 1); + assert_eq!(rig.records.fields.len(), 1); + assert_eq!(rig.records.fields[0].bit_count, bits.len() as u32); + assert_eq!( + rig.records.fields[0].raw_bits.as_deref(), + Some(data.as_slice()) + ); + } + + const PROJECTILE_CNC: &str = + "/Script/ShooterGame.PrecalculatedProjectileMovementComponent_ClassNetCache"; + const PROJECTILE_PARAMS: &str = + "/Script/ShooterGame.PrecalculatedProjectileMovementComponent:MulticastSetPath"; + const PROJECTILE_PARENT: &str = "MulticastSetPath.NetworkedProjectilePath"; + + /// One path point: ElapsedSeconds, Location and Velocity at handles 1-3, + /// optionally without the velocity or with an unknown zero-width member. + fn path_point_array(elapsed: f32, omit_velocity: bool, extra_zero_width: bool) -> Vec { + let mut array = Vec::new(); + array.int_packed(1); // one path point + array.int_packed(1); // index zero + for (handle, payload) in [ + (1, elapsed.to_le_bytes().to_vec()), + (2, vec![0; 24]), + (3, vec![0; 24]), + ] { + if omit_velocity && handle == 3 { + continue; + } + array.int_packed(handle + 1); + array.int_packed((payload.len() * 8) as u32); + array.extend(unpack(&payload)); + } + if extra_zero_width { + array.int_packed(5); // unknown handle 4 + array.int_packed(0); + } + array.int_packed(0); // element terminator + array.int_packed(0); // array terminator + array + } + + /// Send `array` as `MulticastSetPath`'s only parameter through `on_rpc`, + /// with the measured routes of `branch` admitted. + fn projectile_path_rpc(array: &[bool], branch: &str) -> (RecordBuffers, ExportStats) { + let mut rpc = vec![false]; // FunctionParameters checksum bit + rpc.int_packed(1); // parameter handle zero + rpc.int_packed(array.len() as u32); + rpc.extend_from_slice(array); + rpc.int_packed(0); // parameter terminator + let rpc_raw = pack(&rpc); + + let mut rig = Rig::default(); + for (index, path) in [(7, PROJECTILE_CNC), (8, PROJECTILE_PARAMS)] { + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new(path.into(), index, 1)) + .unwrap(); + } + for (index, checksum, name) in [ + (7, 2_336_552_129, "MulticastSetPath"), + (8, 2_930_105_559, "NetworkedProjectilePath"), + ] { + assert!(rig.cache.set_field_on_group( + index, + vrf_schema::NetFieldExport { + handle: 0, + compatible_checksum: checksum, + name: name.into(), + } + )); + } + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from(PROJECTILE_CNC)); + sink.enable_measured_array_routes(branch); + sink.on_rpc( + 0, + rpc.len() as u32, + BitReader::with_bit_len(&rpc_raw, rpc.len() as u64).unwrap(), + ); + let stats = sink.stats.clone(); + (rig.records, stats) + } + + #[test] + fn projectile_path_rpc_rejects_unknown_missing_and_nonfinite_members() { + for ( + case, + elapsed, + extra_zero_width, + omit_velocity, + wanted_children, + wanted_array_errors, + wanted_leaf_errors, + ) in [ + ("valid", 1.5f32, false, false, 3, 0, 0), + ("unknown_zero_width", 1.5f32, true, false, 0, 1, 0), + ("missing_velocity", 1.5f32, false, true, 0, 0, 1), + ("nan_elapsed", f32::NAN, false, false, 0, 0, 1), + ] { + let array = path_point_array(elapsed, omit_velocity, extra_zero_width); + let (records, stats) = projectile_path_rpc(&array, "++Ares-Core+release-13.05"); + assert_eq!(stats.array.errors, wanted_array_errors, "{case}"); + assert_eq!(stats.array_leaf_decode_errors, wanted_leaf_errors, "{case}"); + assert_eq!(records.fields.len(), wanted_children + 1, "{case}"); + let parent = records.fields.last().unwrap(); + assert_eq!( + parent.field_name.as_deref(), + Some(PROJECTILE_PARENT), + "{case}" + ); + assert_eq!(parent.bit_count, array.len() as u32, "{case}"); + assert_eq!( + parent.raw_bits.as_deref(), + Some(pack(&array).as_slice()), + "{case}" + ); + assert!( + records.fields[..records.fields.len() - 1] + .iter() + .all(|row| row + .field_name + .as_deref() + .is_some_and(|name| name.starts_with(PROJECTILE_PARENT))) + ); + } + } + + /// The projectile path is admitted per branch like the flattened arrays: + /// a valid path point expands on the builds whose samples held one, and + /// stays a single raw parameter row where the route was never observed. + #[test] + fn projectile_path_rpc_expands_only_on_admitting_branches() { + let array = path_point_array(2.5, false, false); + for (branch, want_children) in [ + ("++Ares-Core+release-11.06", 0), + ("++Ares-Core+release-11.07", 3), + ("++Ares-Core+release-12.06", 0), + ("++Ares-Core+release-12.09", 3), + ("++Ares-Core+release-12.10", 0), + ("++Ares-Core+release-13.05", 3), + ] { + let (records, stats) = projectile_path_rpc(&array, branch); + assert_eq!(stats.route_children_projectile_path, want_children as u64); + assert_eq!(stats.array.errors, 0, "{branch}"); + assert_eq!(stats.array_leaf_decode_errors, 0, "{branch}"); + assert_eq!(records.fields.len(), want_children + 1, "{branch}"); + let parent = records.fields.last().unwrap(); + assert_eq!( + parent.field_name.as_deref(), + Some(PROJECTILE_PARENT), + "{branch}" + ); + assert_eq!(parent.bit_count, array.len() as u32, "{branch}"); + if want_children > 0 { + assert_eq!(records.fields[0].value_f64, Some(2.5), "{branch}"); + } + } + } + + /// A completed walk, with or without the one trailing alignment bit the + /// grammar allows, is neither a truncation nor a dropped suffix: either + /// counter firing on it would fire on every well-formed payload. + #[test] + fn a_completed_rpc_payload_is_neither_truncated_nor_a_drop() { + for suffix in [0, 1] { + let bits = rpc_payload_with_suffix(suffix); + let data = pack(&bits); + let reader = BitReader::with_bit_len(&data, bits.len() as u64).unwrap(); + let mut rig = Rig::default(); + let mut sink = rig.sink(); + assert!(sink.try_parse_rpc_params(7, reader, Some("SomeFunction"))); + assert_eq!(sink.stats.truncated_rpcs, 0, "{suffix}"); + assert_eq!(sink.stats.rpc_suffix_bits_dropped, 0, "{suffix}"); + } + } + /// An unresolved `AbilitiesAndBuffsComponent` payload that walks cleanly + /// under fc=34 must emit one additive `_cnc_h1` row alongside the + /// preservation row. The RPC handle and payload bits must be correct. + #[test] + fn unresolved_abilities_and_buffs_emits_cnc_rpc_row() { + // fc=34, handle 1, a 32-bit payload of 1s (no walk at lower fc values). + let bits = one_h1_cnc_tail(&[true; 32]); + + let data = pack(&bits); + let bit_count = bits.len() as u32; + + let mut rig = Rig::default(); + let mut sink = abilities_block(&mut rig, false); + let failure = unresolved_failure(89, bit_count); + sink.on_unresolved_class_net_cache_payload(failure, &data); + + // Two rows: the preservation row + one additive CNC RPC row. + assert_eq!( + sink.records.fields.len(), + 2, + "preservation row + one CNC RPC row" + ); + + // Row 0: preservation. + let pres = &sink.records.fields[0]; + assert_eq!( + pres.field_name.as_deref(), + Some(UNRESOLVED_CLASS_NET_CACHE_PAYLOAD_FIELD_NAME) + ); + + // Row 1: the CNC RPC. + let rpc = &sink.records.fields[1]; + assert_eq!(rpc.handle, 1, "handle should be 1"); + assert_eq!(rpc.bit_count, 32, "payload_bits should be 32"); + assert_eq!( + rpc.field_name.as_deref(), + Some("_cnc_h1"), + "field name should identify the RPC handle" + ); + assert!(rpc.raw_bits.is_some(), "raw bits should be extracted"); + assert_eq!(sink.stats.cnc_rpcs_emitted, 1); + assert_eq!(sink.stats.cnc_bruteforce_payloads_attempted, 1); + assert_eq!(sink.stats.cnc_bruteforce_payloads_unwalked, 0); + } + + /// An `AbilitiesAndBuffsComponent` payload the fc=34 walk cannot fit is + /// counted in `cnc_bruteforce_payloads_unwalked`. It declares 64 payload bits + /// and carries 32, the shape a misread handle width leaves; the preservation + /// row still carries every bit. + #[test] + fn unresolved_abilities_and_buffs_that_does_not_walk_is_counted() { + let mut bits = Vec::new(); + bits.serialized_int(1, 34); // handle=1, 6 bits + bits.int_packed(64); // declares 64 payload bits ... + bits.extend(std::iter::repeat_n(true, 32)); // ... but carries 32 + let data = pack(&bits); + let bit_count = bits.len() as u32; + assert!( + decode_cnc_payload(&data, bit_count, ABILITIES_AND_BUFFS_FC).is_none(), + "the fixture must not walk under fc=34, or this tests nothing" + ); + + let mut rig = Rig::default(); + let mut sink = abilities_block(&mut rig, false); + let failure = unresolved_failure(89, bit_count); + sink.on_unresolved_class_net_cache_payload(failure, &data); + + assert_eq!(sink.stats.cnc_bruteforce_payloads_attempted, 1); + assert_eq!(sink.stats.cnc_bruteforce_payloads_unwalked, 1); + assert_eq!(sink.stats.cnc_rpcs_emitted, 0); + assert_eq!(sink.records.fields.len(), 1, "only the preservation row"); + let preserved = &sink.records.fields[0]; + assert_eq!( + preserved.field_name.as_deref(), + Some(UNRESOLVED_CLASS_NET_CACHE_PAYLOAD_FIELD_NAME) + ); + assert_eq!(preserved.raw_bits.as_deref(), Some(data.as_slice())); + } + + /// An unresolved payload for a group OTHER than AbilitiesAndBuffsComponent + /// produces no CNC rows: the brute force is gated. The payload walks under + /// fc=34 (`unresolved_abilities_and_buffs_emits_cnc_rpc_row`), so only the + /// group-path guard can stop it. + #[test] + fn unresolved_payload_for_other_group_emits_no_cnc_rows() { + let bits = one_h1_cnc_tail(&[true; 32]); + let data = pack(&bits); + let bit_count = bits.len() as u32; + + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + let header = subobject_block(200, false); + sink.on_content_block(3, NetworkGuid(89), &header); + // current_group_path resolves to a bare name that is NOT + // AbilitiesAndBuffsComponent. + + let failure = unresolved_failure(89, bit_count); + sink.on_unresolved_class_net_cache_payload(failure, &data); + + // Only the preservation row, no CNC rows: the payload walks (proven + // above), so only the group-path gate can be what stops it here. + assert_eq!(sink.records.fields.len(), 1); + assert_eq!(sink.stats.cnc_rpcs_emitted, 0); + // Gated out before the walk, so it was never attempted -- and an + // attempt that did not walk is not what happened either. + assert_eq!(sink.stats.cnc_bruteforce_payloads_attempted, 0); + assert_eq!(sink.stats.cnc_bruteforce_payloads_unwalked, 0); + } + /// A dormancy close is exported as `dormant`, not as a despawn; both still + /// emit a row and count as closes. + #[test] + fn a_dormancy_close_is_not_recorded_as_a_despawn() { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + sink.on_actor_close(3, NetworkGuid(42), false); + sink.on_actor_close(4, NetworkGuid(43), true); + + assert_eq!(sink.records.actors.len(), 2, "both closes are still rows"); + assert_eq!( + sink.records.actors[0].event, "close", + "a real despawn keeps the name every consumer already reads" + ); + assert_eq!( + sink.records.actors[1].event, "dormant", + "a dormancy close must be distinguishable from a despawn" + ); + // Both still count as closes: the actor channel did close. + assert_eq!(sink.stats.actor_closes, 2); + } + + /// Deleted and live blocks are both content blocks, as in vrf-net's + /// `NetStats::content_blocks`, which `tools/verify_build_corpus.py` checks + /// `sink_content_blocks` against: a replay without deleted blocks cannot + /// notice `on_deleted_block` forgetting its count. + #[test] + fn deleted_and_live_blocks_both_advance_the_sink_block_tally() { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + let header = actor_block(true); + + sink.on_content_block(7, NetworkGuid(1234), &header); + assert_eq!(sink.stats.content_blocks, 1); + sink.on_deleted_block(7, NetworkGuid(1234), &header); + assert_eq!(sink.stats.content_blocks, 2); + } + + /// Every RPC callback advances `rpcs_emitted` exactly once, whatever row + /// shape it produces, because vrf-net counts `NetStats::rpcs` once per + /// callback. The zero-bit marker row is the branch a misplaced increment + /// would most easily skip. + #[test] + fn every_rpc_shape_advances_the_sink_rpc_tally_once() { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + sink.on_rpc(5, 0, BitReader::with_bit_len(&[], 0).unwrap()); + assert_eq!(sink.stats.rpcs_emitted, 1, "zero-bit marker row"); + sink.on_rpc(6, 8, BitReader::with_bit_len(&[0xA5], 8).unwrap()); + assert_eq!(sink.stats.rpcs_emitted, 2, "whole-payload fallback row"); + assert_eq!(sink.records.fields.len(), 2); + } + + /// A dynamic open carries its spawn location, rotation and velocity, each + /// in its own columns; a static open and every close carry none. + #[test] + fn spawn_vectors_reach_their_own_columns_on_opens_only() { + use vrf_net::types::FRotator; + let v = |x, y, z| Some(FVector { x, y, z }); + let mut rig = Rig::default(); + let mut sink = rig.sink(); + sink.on_actor_open(&ActorChannelState { + spawn_location: v(1.5, -2.0, 3.0), + spawn_rotation: Some(FRotator { + pitch: 4.0, + yaw: 5.0, + roll: 6.0, + }), + spawn_velocity: v(-1000.0, 0.0, 3200.0), + ..channel_open(3, 42, 8) + }); + sink.on_actor_open(&channel_open(4, 43, 0)); + sink.on_actor_close(3, NetworkGuid(42), false); + let spawn = |row: &ActorRecord| { + [ + [row.spawn_x, row.spawn_y, row.spawn_z], + [row.spawn_pitch, row.spawn_yaw, row.spawn_roll], + [row.spawn_vx, row.spawn_vy, row.spawn_vz], + ] + }; + let rows: Vec<_> = sink.records.actors.iter().map(spawn).collect(); + assert_eq!( + rows[0], + [ + [Some(1.5), Some(-2.0), Some(3.0)], + [Some(4.0), Some(5.0), Some(6.0)], + [Some(-1000.0), Some(0.0), Some(3200.0)], + ] + ); + assert_eq!(rows[1..], [[[None; 3]; 3]; 2]); + } + + /// A static actor's close row, like its open row, gets no class_path from + /// its own GUID path (the level's instance name, not a class). + #[test] + fn a_static_actors_close_row_does_not_fabricate_a_class_path_from_its_own_guid() { + let mut rig = Rig::default(); + // The actor's own GUID path -- an instance name, e.g. what a level + // placement looks like on the wire -- must not read back as a class. + rig.cache.set_net_guid_path(42, "WindowShieldA1", None); + let mut sink = rig.sink(); + + // No archetype: `NetworkGuid(0)` is invalid, so `on_actor_open` never + // registers a channel archetype for it. + sink.on_actor_open(&channel_open(3, 42, 0)); + sink.on_actor_close(3, NetworkGuid(42), false); + + assert_eq!(sink.records.actors[0].class_path, None, "open row"); + assert_eq!( + sink.records.actors[1].class_path, None, + "close row must agree with the open row, not fabricate a class \ + from the actor's own instance-name path" + ); + } + + #[test] + fn destroyed_channel_archetypes_are_retired_but_dormant_ones_survive() { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + + sink.on_actor_open(&channel_open(3, 42, 8)); + sink.on_actor_open(&channel_open(4, 43, 9)); + sink.on_actor_close(3, NetworkGuid(42), false); + sink.on_actor_close(4, NetworkGuid(43), true); + + assert!( + channel_archetype(sink.channel_state, 3, NetworkGuid(42)).is_none(), + "destroyed channels must not accumulate sink-side archetypes" + ); + assert_eq!( + channel_archetype(sink.channel_state, 4, NetworkGuid(43)), + Some(NetworkGuid(9)), + "dormancy preserves the class needed when the same actor wakes" + ); + } + + /// `Subject` and `SpawnedCharacter` are captured on the bomb PlayerState and + /// on Swiftplay's (through `canonical_group`). A 0 is a disconnect, never a + /// body; a reconnect's pawn is appended and a repeat moves to the end, so + /// the last is the current body; `PossessedCharacter` (a camera, drone or + /// ability pawn) never enters the history. + #[test] + fn player_identity_keeps_the_spawned_body() { + const SWIFT: &str = "/Game/GameModes/_Development/Swiftplay_EndOfRoundCredits/Swiftplay_EoRCredits_PlayerState.Swiftplay_EoRCredits_PlayerState_C"; + const SPAWNED: &str = "SpawnedCharacter"; + const POSSESSED: &str = "PossessedCharacter"; + for (path, writes, want) in [ + (BOMB_PLAYER_STATE, vec![(SPAWNED, 576)], vec![576]), + (SWIFT, vec![(SPAWNED, 576)], vec![576]), + ( + BOMB_PLAYER_STATE, + vec![(SPAWNED, 1368), (SPAWNED, 0)], + vec![1368], + ), + ( + BOMB_PLAYER_STATE, + vec![(SPAWNED, 1510), (SPAWNED, 0), (SPAWNED, 45530)], + vec![1510, 45530], + ), + ( + BOMB_PLAYER_STATE, + vec![(SPAWNED, 7), (SPAWNED, 8), (SPAWNED, 7), (SPAWNED, 7)], + vec![8, 7], + ), + (BOMB_PLAYER_STATE, vec![(SPAWNED, 0)], vec![]), + (BOMB_PLAYER_STATE, vec![(POSSESSED, 412)], vec![]), + ( + BOMB_PLAYER_STATE, + vec![(SPAWNED, 20), (POSSESSED, 20), (POSSESSED, 412)], + vec![20], + ), + ] { + let mut rig = Rig::default(); + let mut sink = rig.sink(); + sink.current_group_path = Arc::from(path); + sink.current_actor_guid = 42; + sink.record_player_identity(Some("Subject"), Some("uuid-here"), None); + for &(name, guid) in &writes { + sink.record_player_identity(Some(name), None, Some(guid)); + } + let entry = &sink.channel_state.players[&42]; + assert_eq!(entry.subject.as_deref(), Some("uuid-here"), "{path}"); + assert_eq!(entry.character_net_guids, want, "{path} {writes:?}"); + } + } + + /// The aggregate does not inherit the 32-line window's cap: 100 failed + /// blocks stay 100 in the aggregate while the line list stops at 32. + #[test] + fn failures_past_the_line_cap_are_all_aggregated() { + let mut rig = Rig::default(); + rig.state.enable_failure_aggregate(false); + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from("/Script/ShooterGame.AresAbilitySystemComponent")); + for i in 0..100 { + sink.on_stream_failure(abandoned_tail(i % 2)); + } + + assert_eq!( + sink.channel_state.stream_failures().len(), + 32, + "the line window stays capped" + ); + let agg = sink.channel_state.failures.as_ref().unwrap(); + assert_eq!(agg.total_failures(), 100, "the aggregate never caps"); + assert_eq!(agg.real_loss(), 100); + // Two cells: consumed_bits is a key dimension, so the 50 failures that + // stopped at 0 and the 50 that stopped at 1 stay apart, and each cell + // still counts its whole population. + let cells = agg.cells_sorted(); + assert_eq!(cells.len(), 2, "two distinct consumed values, two cells"); + let counted: u64 = cells.iter().map(|(_, cell)| cell.count).sum(); + assert_eq!(counted, 100, "the cells together hold every failure"); + for (key, cell) in &cells { + assert_eq!(cell.count, 50, "{:?}", key.consumed_bits); + } + assert_eq!( + cells[0].0.cause, + vrf_net::pipeline::StreamFailureCause::AbandonedTail + ); + assert_eq!(cells[0].0.record_handle, Some(3)); + } + + /// A preserved unresolved RPC failure and a genuinely lost RepLayout + /// failure must land in separate aggregate buckets, so real loss is never + /// inflated by payloads that are on disk as preservation rows. + #[test] + fn preserved_unresolved_failures_are_separated_from_real_loss() { + let mut rig = Rig::default(); + rig.state.enable_failure_aggregate(true); + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from("AbilitiesAndBuffsComponent")); + + // The framing layer's exact sequence for an unresolved block: + // on_stream_failure, then on_unresolved_class_net_cache_payload. + let unresolved = unresolved_failure(9, 64); + for _ in 0..40 { + sink.on_stream_failure(unresolved); + sink.on_unresolved_class_net_cache_payload(unresolved, &[0xDE, 0xAD]); + } + + // ...plus a real RepLayout loss. + sink.set_current_group_path(Arc::from("/Script/ShooterGame.AresAbilitySystemComponent")); + sink.on_stream_failure(abandoned_tail(185)); + + let agg = sink.channel_state.failures.as_ref().unwrap(); + assert_eq!(agg.total_failures(), 41); + assert_eq!(agg.preserved_unresolved(), 40); + assert_eq!(agg.real_loss(), 1, "only the RepLayout block is loss"); + let cells = agg.cells_sorted(); + assert_eq!(cells.len(), 2); + // Sorted by count: the preserved cell first, its samples carrying the + // real payload bytes recorded by on_unresolved. + assert_eq!( + cells[0].0.cause, + vrf_net::pipeline::StreamFailureCause::UnresolvedFunctionCount + ); + assert_eq!(cells[0].1.samples.len(), 3, "sample cap, not 40"); + assert_eq!(cells[0].1.samples[0].payload_hex.as_deref(), Some("dead")); + assert_eq!(cells[1].0.kind, vrf_net::pipeline::StreamKind::RepLayout); + assert_eq!( + cells[1].0.group_path.as_ref(), + "/Script/ShooterGame.AresAbilitySystemComponent" + ); + } + + /// Taking the aggregate drains it, so a checkpoint pass that creates one + /// channel state per chunk cannot double-count a chunk's failures into + /// the caller's totals. + #[test] + fn taking_the_aggregate_drains_it() { + let mut rig = Rig::default(); + rig.state.enable_failure_aggregate(false); + let mut sink = rig.sink(); + sink.set_current_group_path(Arc::from("SomeGroup")); + sink.on_stream_failure(abandoned_tail(8)); + + let taken = sink.channel_state.take_failure_aggregate(); + assert_eq!(taken.total_failures(), 1); + assert!( + sink.channel_state.failures.is_none(), + "taking disables the drained aggregate" + ); + } + + // (vendor: `export` feature gate removed) + #[test] + fn checkpoint_block_spans_include_every_emitted_child_and_empty_block() { + let mut rig = Rig::default(); + rig.cache + .add_export_group(vrf_schema::NetFieldExportGroup::new( + "ActorGroup".to_owned(), + 1, + 4, + )) + .unwrap(); + rig.cache.set_net_guid_path(9, "ActorGroup", None); + let mut sink = rig.sink(); + sink.enable_checkpoint_block_context( + vrf_export::CheckpointIdentity { + checkpoint_index: 2, + checkpoint_id: Arc::from("duplicate-id"), + }, + 100, + 7, + ); + sink.time_ms = 12; + sink.packet_id = 3; + let header = actor_block(true); + sink.on_content_block(4, NetworkGuid(9), &header); + sink.push_field(FieldValues { + field_name: Some(Arc::from("raw-parent")), + bit_count: 8, + raw_bits: Some(SmallVec::from_slice(&[0xaa])), + ..Default::default() + }); + sink.push_field(FieldValues { + field_name: Some(Arc::from("typed-child")), + value_i64: Some(5), + ..Default::default() + }); + sink.on_content_block(4, NetworkGuid(9), &header); + assert_eq!(sink.records.checkpoint_blocks.len(), 2); + let first = &sink.records.checkpoint_blocks[0]; + assert_eq!( + ( + first.block_index, + first.field_row_start, + first.field_row_count + ), + (7, 100, 2) + ); + assert_eq!(first.checkpoint.checkpoint_index, 2); + assert_eq!(first.group_resolution_source, "actor_guid_path"); + assert!(!first.resolution_memo_hit); + let empty = &sink.records.checkpoint_blocks[1]; + assert_eq!( + ( + empty.block_index, + empty.field_row_start, + empty.field_row_count + ), + (8, 102, 0) + ); + assert!(empty.resolution_memo_hit); + assert_eq!(empty.group_resolution_source, "actor_guid_path"); + let explicit_delete = ContentBlockHeader { + is_deleted: true, + object_net_guid: NetworkGuid(10), + outer_net_guid: NetworkGuid(9), + ..Default::default() + }; + sink.on_deleted_block(4, NetworkGuid(9), &explicit_delete); + let invalid_class_delete = ContentBlockHeader { + has_class_net_guid: true, + ..explicit_delete + }; + sink.on_deleted_block(4, NetworkGuid(9), &invalid_class_delete); + assert_eq!(sink.records.checkpoint_blocks[2].class_net_guid, None); + assert_eq!(sink.records.checkpoint_blocks[3].class_net_guid, Some(0)); + assert_eq!( + sink.records.fields[0].raw_bits.as_deref(), + Some(&[0xaa][..]) + ); + assert_eq!(sink.records.fields[1].value_i64, Some(5)); + } +} diff --git a/native/replay/tests/corpus.rs b/native/replay/tests/corpus.rs new file mode 100644 index 000000000..40512bb0a --- /dev/null +++ b/native/replay/tests/corpus.rs @@ -0,0 +1,215 @@ +//! Corpus tests over real replays: set `ICARUS_REPLAY_CORPUS` to a directory +//! of `.vrf` files (skipped otherwise). Decoding needs Oodle: run with +//! `--features dev-vectors` and `ICARUS_OODLE_VECTORS` set. + +use std::path::PathBuf; + +use icarus_replay::container::{self, MOVEMENT_RECORD_BYTES, MovementSample}; +use icarus_replay::decode::Control; +use icarus_replay::{ErrorCode, header, oodle}; + +fn corpus() -> Vec { + let Some(dir) = std::env::var_os("ICARUS_REPLAY_CORPUS") else { + eprintln!("ICARUS_REPLAY_CORPUS unset: corpus test skipped"); + return Vec::new(); + }; + let mut files: Vec = std::fs::read_dir(dir) + .expect("ICARUS_REPLAY_CORPUS is a directory") + .map(|e| e.expect("readable").path()) + .filter(|p| p.extension().is_some_and(|e| e == "vrf")) + .collect(); + files.sort(); + assert!(!files.is_empty(), "ICARUS_REPLAY_CORPUS holds no .vrf"); + files +} + +fn decoder_available() -> bool { + cfg!(feature = "dev-vectors") && std::env::var_os("ICARUS_OODLE_VECTORS").is_some() +} + +#[test] +fn every_replay_probes_fast_without_decompressing() { + for path in corpus() { + let started = std::time::Instant::now(); + let probe = header::probe(&path).expect("probe"); + let elapsed = started.elapsed(); + eprintln!("{} probe {:?}", probe.id, elapsed); + assert!(elapsed.as_millis() < 50, "{} took {elapsed:?}", probe.id); + assert!(probe.supported, "{} build {}", probe.id, probe.build); + assert_eq!(probe.decoder_version, header::DECODER_VERSION); + assert_eq!(probe.players.len(), 10, "{}", probe.id); + assert!(probe.players.iter().all(|p| p.agent_id.is_some())); + } +} + +#[test] +fn a_build_without_a_decoder_reports_unsupported_compression() { + for path in corpus().into_iter().take(1) { + let err = icarus_replay::decode_file_with(&path, &oodle::Unavailable, Control::default()) + .expect_err("no decoder"); + assert_eq!(err.code, ErrorCode::UnsupportedCompression, "{err}"); + assert!(err.build.is_some()); + } +} + +#[test] +fn every_replay_decodes_to_the_contract() { + if !decoder_available() { + eprintln!("no Oodle decoder: decode test skipped"); + return; + } + for path in corpus() { + let started = std::time::Instant::now(); + let buffer = icarus_replay::decode_file(&path, Control::default()).expect("decode"); + let parts = container::read(&buffer).expect("container"); + assert_eq!(parts.format_version, 1); + let doc: serde_json::Value = serde_json::from_str(parts.json).expect("JSON"); + let id = doc["match"]["id"].as_str().unwrap().to_owned(); + eprintln!("{id} decoded in {:?}", started.elapsed()); + + assert_eq!(doc["decoder"]["version"], header::DECODER_VERSION); + assert_eq!(doc["quality"]["transformVerified"], true); + assert_eq!(doc["quality"]["decodeErrors"], 0); + + let players = doc["players"].as_array().unwrap(); + let red = players.iter().filter(|p| p["team"] == "Red").count(); + let blue = players.iter().filter(|p| p["team"] == "Blue").count(); + assert_eq!((red, blue), (5, 5), "{id}: teams"); + + // Kills and rounds come one per Event-chunk death and round start. + let rounds = doc["rounds"].as_array().unwrap(); + assert!(!rounds.is_empty()); + for (i, r) in rounds.iter().enumerate() { + assert_eq!(r["index"], i); + assert!( + r["startMs"].as_i64() <= r["endMs"].as_i64(), + "{id} round {i}" + ); + assert!(r["attackingTeam"].is_string(), "{id} round {i}: attackers"); + } + let kills = doc["kills"].as_array().unwrap(); + let subjects: Vec<&str> = players + .iter() + .map(|p| p["subject"].as_str().unwrap()) + .collect(); + for k in kills { + assert!(subjects.contains(&k["victim"].as_str().unwrap())); + } + + // Movement: every span in bounds, sorted, no parked record. + for (subject, span) in doc["movement"].as_object().unwrap() { + let offset = span["offset"].as_u64().unwrap() as usize; + let count = span["count"].as_u64().unwrap() as usize; + assert!(count > 0, "{id} {subject}: no movement"); + let bytes = &parts.blob[offset..offset + count * MOVEMENT_RECORD_BYTES]; + let mut last = i32::MIN; + for record in bytes.chunks(MOVEMENT_RECORD_BYTES) { + let s = MovementSample::read(record); + assert!(s.time_ms >= last, "{id} {subject}: unsorted"); + assert!( + !(s.x < -40_000.0 && s.z < -40_000.0), + "{id} {subject}: parked at {s:?}" + ); + assert!((-180.0..=180.0).contains(&s.pitch)); + last = s.time_ms; + } + } + } +} + +/// The guard's measure on every replay read with its own transform: far from +/// both thresholds, so neither a quiet match nor a busy one trips it. +#[test] +fn every_replay_passes_the_guard_with_margin() { + if !decoder_available() { + return; + } + let decompressor = oodle::default_decompressor(); + for path in corpus() { + let data = std::fs::read(&path).unwrap(); + let build = header::probe(&path).unwrap().build; + let (verdict, sample) = + icarus_replay::measure_guard(&data, &build, decompressor.as_ref()).unwrap(); + eprintln!( + "{}: {:.1}% zero bytes, {:.1}% bits set over {} bytes of {} payloads", + path.file_name().unwrap().to_string_lossy(), + verdict.zero_share * 100.0, + verdict.bit_density * 100.0, + sample.bytes, + sample.payloads + ); + assert!(verdict.verified); + assert!( + verdict.zero_share > 2.0 * icarus_replay::guard::MIN_ZERO_SHARE, + "{verdict:?}" + ); + assert!( + verdict.bit_density < icarus_replay::guard::MAX_BIT_DENSITY - 0.05, + "{verdict:?}" + ); + } +} + +/// A transform that is registered but wrong for the file decodes into noise; +/// the guard must refuse it rather than return an empty replay. +#[test] +fn a_wrong_transform_fails_the_guard() { + if !decoder_available() { + eprintln!("no Oodle decoder: guard test skipped"); + return; + } + let decompressor = oodle::default_decompressor(); + for path in corpus().into_iter().take(2) { + let data = std::fs::read(&path).unwrap(); + let build = header::probe(&path).unwrap().build; + let wrong = if build == "++Ares-Core+release-13.01" { + "++Ares-Core+release-13.02" + } else { + "++Ares-Core+release-13.01" + }; + let err = + icarus_replay::decode_bytes(&data, wrong, decompressor.as_ref(), Control::default()) + .expect_err("a wrong transform must not decode"); + eprintln!("{}: {}", path.display(), err.message); + assert_eq!(err.code, ErrorCode::TransformCheckFailed, "{err}"); + } +} + +#[test] +fn cancel_stops_a_decode() { + if !decoder_available() { + return; + } + for path in corpus().into_iter().take(1) { + let cancel = std::sync::atomic::AtomicU32::new(1); + let err = icarus_replay::decode_file( + &path, + Control { + progress: None, + cancel: Some(&cancel), + }, + ) + .expect_err("cancelled"); + assert_eq!(err.code, ErrorCode::Cancelled); + assert!(err.build.is_some(), "the header was read"); + } +} + +#[test] +fn progress_reaches_done() { + if !decoder_available() { + return; + } + for path in corpus().into_iter().take(1) { + let progress = std::sync::atomic::AtomicU32::new(0); + icarus_replay::decode_file( + &path, + Control { + progress: Some(&progress), + cancel: None, + }, + ) + .unwrap(); + assert_eq!(progress.load(std::sync::atomic::Ordering::Relaxed), 10_000); + } +} diff --git a/test/replay_ability_catalog_test.dart b/test/replay_ability_catalog_test.dart new file mode 100644 index 000000000..5c4f869ed --- /dev/null +++ b/test/replay_ability_catalog_test.dart @@ -0,0 +1,671 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/replay/replay_ability_catalog.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart'; + +void main() { + late CoordinateSystem coordinates; + + setUp(() { + coordinates = CoordinateSystem(playAreaSize: const Size(1600, 900)); + }); + + ReplayUtility utility( + String classPath, { + required ReplayVector position, + double? yaw, + List points = const [], + List path = const [], + }) => + ReplayUtility( + id: 1, + classPath: classPath, + spawnMs: 1000, + position: position, + yaw: yaw, + points: points, + path: path, + ); + + PlacedAbility place( + ReplayUtility utility, + ReplayMapProjection projection, { + int timeMs = 1000, + }) => + replayAbilityFor(utility.classPath)!.place( + utility: utility, + projection: projection, + timeMs: timeMs, + id: 'u', + isAlly: true, + ); + + double mapScaleOf(ReplayMapProjection projection) => + Maps.mapScale[projection.map]!; + + /// Where the editor draws the ability's anchor, in screen pixels. + Offset drawnAnchor(PlacedAbility ability, ReplayMapProjection projection) => + screenAnchorForAbility( + ability: ability, + coordinateSystem: coordinates, + mapScale: mapScaleOf(projection), + ); + + Offset screenOf(ReplayMapProjection projection, ReplayVector point) => + coordinates.coordinateToScreen(projection.toWorld(point.x, point.y)); + + Offset unitFor(double rotation) => + Offset(math.sin(rotation), -math.cos(rotation)); + + /// Screen distance from a square's anchor to the near edge of its area. + double anchorToNearEdge(SquareAbility shape, double mapScale) => + coordinates.scale( + storedAbilityAnchor(ability: shape, mapScale: mapScale).dy - + shape.height * mapScale, + ); + + Matcher near(Offset expected, {double pixels = 1}) => predicate( + (actual) => (actual - expected).distance <= pixels, + 'within $pixels px of $expected', + ); + + group('class paths seen in the 13.00 replays', () { + test('map onto the Icarus ability they draw', () { + for (final MapEntry(key: path, value: (agent, index)) + in observedMapped.entries) { + final entry = replayAbilityFor(path); + expect(entry, isNotNull, reason: path); + expect((entry!.agent, entry.abilityIndex), (agent, index), + reason: path); + } + }); + + test('leave projectiles, cosmetics and bookkeeping out', () { + for (final path in observedIgnored) { + expect(replayAbilityFor(path), isNull, reason: path); + } + }); + }); + + test('every entry points at a real Icarus ability', () { + for (final asset in replayAbilityAssetNames) { + final entry = replayAbilityFor(asset)!; + final abilities = AgentData.agents[entry.agent]!.abilities; + expect(entry.abilityIndex, inInclusiveRange(0, abilities.length - 1), + reason: asset); + expect(entry.ability.abilityData, isNotNull, reason: asset); + } + }); + + test('every Icarus agent has utility in the catalog', () { + final covered = { + for (final asset in replayAbilityAssetNames) + replayAbilityFor(asset)!.agent, + }; + expect(covered, containsAll(AgentType.values)); + }); + + test('rectangles draw as Icarus squares, which run from their start', () { + for (final asset in replayAbilityAssetNames) { + final entry = replayAbilityFor(asset)!; + if (entry.kind != ReplayUtilityKind.rectangle) continue; + expect(entry.ability.abilityData, isA(), reason: asset); + } + }); + + test('every entry places on a map', () { + final projection = ReplayMapProjection.forMap(MapValue.ascent); + for (final asset in replayAbilityAssetNames) { + final placed = place( + utility( + '/Game/Characters/X/S0/$asset.${asset}_C', + position: const ReplayVector(1000, 2000, 100), + yaw: 30, + points: const [ + ReplayVector(1000, 2000, 100), + ReplayVector(1500, 2500, 100), + ], + ), + projection, + ); + expect(placed.position.dx.isFinite && placed.position.dy.isFinite, isTrue, + reason: asset); + expect(placed.rotation.isFinite, isTrue, reason: asset); + } + }); + + test('a smoke centres on the projected game position', () { + // Jett's Cloudburst on Sunset. + final projection = ReplayMapProjection.forMap(MapValue.sunset); + const at = ReplayVector(-2100, -3600, 166); + final smoke = place( + utility( + '/Game/Characters/Wushu/S0/Ability_4/GameObject_Wushu_4_SmokeZone.' + 'GameObject_Wushu_4_SmokeZone_C', + position: at, + yaw: 120, + ), + projection, + ); + + expect((smoke.data.type, smoke.data.index), (AgentType.jett, 0)); + expect(drawnAnchor(smoke, projection), near(screenOf(projection, at))); + // A dropped ability saves its top-left, not its centre. + expect((smoke.position - projection.toWorld(at.x, at.y)).distance, + greaterThan(1)); + }); + + test('a marker follows its path to where it is at the moment', () { + final projection = ReplayMapProjection.forMap(MapValue.split); + const later = ReplayVector(1200, 400, 300); + final bot = utility( + '/Game/Characters/Clay/S0/Ability_E/Pawn_Clay_E_Boomba.' + 'Pawn_Clay_E_Boomba_C', + position: const ReplayVector(800, 400, 300), + path: const [ReplayPathPoint(2000, later)], + ); + + expect(drawnAnchor(place(bot, projection, timeMs: 2500), projection), + near(screenOf(projection, later))); + }); + + test("Sage's wall centres on its actor and runs across its yaw", () { + // c8989335 (Split) at 79558 ms. Its four 2.6 m segments sit at + // yaw +/- 90 degrees, 1.3 m and 3.9 m out. + final projection = ReplayMapProjection.forMap(MapValue.split); + const at = ReplayVector(6432.3, -7824.8, 100); + final wall = place( + utility( + '/Game/Characters/Thorne/S0/Ability_E/GameObject_Thorne_E_Wall_' + 'Fortifying.GameObject_Thorne_E_Wall_Fortifying_C', + position: at, + yaw: 347.68, + ), + projection, + ); + + expect(drawnAnchor(wall, projection), near(screenOf(projection, at))); + expect( + wall.rotation, closeTo(projection.rotationForYaw(347.68 + 90), 1e-9)); + }); + + test("Neon's Fast Lane runs from its first tunnel point to its last", () { + // c8989335 (Split), the tunnel at 57646 ms. + final projection = ReplayMapProjection.forMap(MapValue.split); + const start = ReplayVector(6081, -4858, 210); + const end = ReplayVector(6399, -8218, 170); + final lane = place( + utility( + '/Game/Characters/Sprinter/S0/Ability_4/GameObject_Sprinter_4_Tunnel.' + 'GameObject_Sprinter_4_Tunnel_C', + position: start, + yaw: 275.4, + points: const [start, ReplayVector(6093, -4983, 210), end], + ), + projection, + ); + final shape = lane.data.abilityData! as ResizableSquareAbility; + final mapScale = mapScaleOf(projection); + final anchor = drawnAnchor(lane, projection); + final direction = unitFor(lane.rotation); + final nearEdge = anchorToNearEdge(shape, mapScale); + final drawnLength = + coordinates.scale(shape.resolveLength(lane.length) * mapScale); + + expect(anchor + direction * nearEdge, near(screenOf(projection, start))); + expect(anchor + direction * (nearEdge + drawnLength), + near(screenOf(projection, end))); + }); + + test("Breach's Fault Line starts at the fissure and runs along its yaw", () { + // dc078274 (Sunset): the fissure spawns 8 m ahead of Breach, on yaw. + final projection = ReplayMapProjection.forMap(MapValue.sunset); + const at = ReplayVector(955, -1412, 300); + final fissure = place( + utility( + '/Game/Characters/Breach/S0/Ability_E/GameObject_Breach_E_SweetSpot' + 'Fissure.GameObject_Breach_E_SweetSpotFissure_C', + position: at, + yaw: 270.3, + ), + projection, + ); + final shape = fissure.data.abilityData! as SquareAbility; + final nearEdge = anchorToNearEdge(shape, mapScaleOf(projection)); + + expect(fissure.rotation, closeTo(projection.rotationForYaw(270.3), 1e-9)); + expect( + drawnAnchor(fissure, projection) + unitFor(fissure.rotation) * nearEdge, + near(screenOf(projection, at))); + }); + + test("Deadlock's Barrier Mesh arms reach each deployer's destination", () { + // 1fb53a2c (Summit) at 64113 ms: the root and its four + // CableDeployer MulticastInitialize.Destination values. + final projection = ReplayMapProjection.forMap(MapValue.summit); + const root = ReplayVector(7828.8, 2866.4, 157.6); + const destinations = [ + ReplayVector(8181.6, 3219.3, 101.7), + ReplayVector(7624.5, 3070.7, 101.1), + ReplayVector(7419.9, 2457.6, 97.7), + ReplayVector(8495.3, 2200.0, 100.0), + ]; + final mesh = place( + utility( + '/Game/Characters/Cable/S0/Ability_E/GameObject_CableJamRoot.' + 'GameObject_CableJamRoot_C', + position: root, + points: destinations, + ), + projection, + ); + final mapScale = mapScaleOf(projection); + final centre = drawnAnchor(mesh, projection); + final ends = [ + for (final arm in DeadlockBarrierMeshArm.values) + centre + + _rotate(arm.unitVector, mesh.rotation) * + coordinates.scale(deadlockBarrierMeshArmLengthVirtual( + mesh.armLengthsMeters[arm.index], + mapScale, + )), + ]; + + expect((mesh.data.type, mesh.data.index), (AgentType.deadlock, 2)); + expect(centre, near(screenOf(projection, root))); + for (final destination in destinations) { + final target = screenOf(projection, destination); + final closest = ends.reduce( + (a, b) => (a - target).distance <= (b - target).distance ? a : b); + expect(closest, near(target)); + } + }); +} + +/// Turns [vector] clockwise on screen, as Transform.rotate does. +Offset _rotate(Offset vector, double angle) => Offset( + vector.dx * math.cos(angle) - vector.dy * math.sin(angle), + vector.dx * math.sin(angle) + vector.dy * math.cos(angle), + ); + +// Every utility class path in the seven 13.00 replays in +// AppData/Local/VALORANT/Saved/Demos (vrfkit 0.2.5 actors.parquet), with the +// Icarus ability it draws as. +const observedMapped = { + '/Game/Characters/AggroBot/S0/Ability_4/Patch_Aggrobot_C_ExplodeyPatch.Patch_Aggrobot_C_ExplodeyPatch_C': + (AgentType.gekko, 0), + '/Game/Characters/AggroBot/S0/Ability_E/Projectile_E_Aggrobot_DiscTurret_PowerWave.Projectile_E_Aggrobot_DiscTurret_PowerWave_C': + (AgentType.gekko, 2), + '/Game/Characters/AggroBot/S0/Ability_Q/Pawn_Aggrobot_SeekerNade.Pawn_Aggrobot_SeekerNade_C': + (AgentType.gekko, 1), + '/Game/Characters/AggroBot/S0/Ability_X/Pawn_Aggrobot_RollyPolly.Pawn_Aggrobot_RollyPolly_C': + (AgentType.gekko, 3), + '/Game/Characters/BountyHunter/S0/Ability_4/Pawn_BountyHunter_4_WolfHound.Pawn_BountyHunter_4_WolfHound_C': + (AgentType.fade, 0), + '/Game/Characters/BountyHunter/S0/Ability_E/GameObject_BountyHunter_E_LoSReveal_Source_Reactivate.GameObject_BountyHunter_E_LoSReveal_Source_Reactivate_C': + (AgentType.fade, 2), + '/Game/Characters/BountyHunter/S0/Ability_E/Projectile_E_BountyHunter_Divebomb.Projectile_E_BountyHunter_Divebomb_C': + (AgentType.fade, 2), + '/Game/Characters/BountyHunter/S0/Ability_Q/GameObject_Q_BountyHunter_Tether_SphereExpansion.GameObject_Q_BountyHunter_Tether_SphereExpansion_C': + (AgentType.fade, 1), + '/Game/Characters/BountyHunter/S0/Ability_X/GameObject_BountyHunter_X_WaveForm.GameObject_BountyHunter_X_WaveForm_C': + (AgentType.fade, 3), + '/Game/Characters/Breach/S0/Ability_4/GameObject_Breach_4_FusionBlast.GameObject_Breach_4_FusionBlast_C': + (AgentType.breach, 0), + '/Game/Characters/Breach/S0/Ability_E/GameObject_Breach_E_SweetSpotFissure.GameObject_Breach_E_SweetSpotFissure_C': + (AgentType.breach, 2), + '/Game/Characters/Breach/S0/Ability_Q/Projectile_Breach_Q_ThroughWalls_Flash.Projectile_Breach_Q_ThroughWalls_Flash_C': + (AgentType.breach, 1), + '/Game/Characters/Cable/S0/Ability_4/Patch_NetToss.Patch_NetToss_C': ( + AgentType.deadlock, + 0 + ), + '/Game/Characters/Cable/S0/Ability_E/GameObject_CableJamRoot.GameObject_CableJamRoot_C': + (AgentType.deadlock, 2), + '/Game/Characters/Cable/S0/Ability_Q/GameObject_SoundSensor_SweetSpotFissure.GameObject_SoundSensor_SweetSpotFissure_C': + (AgentType.deadlock, 1), + '/Game/Characters/Cable/S0/Ability_Q/GameObject_StealthingTrap_SoundSensor.GameObject_StealthingTrap_SoundSensor_C': + (AgentType.deadlock, 1), + '/Game/Characters/Cable/S0/Ability_X/Actor_FishingHook.Actor_FishingHook_C': ( + AgentType.deadlock, + 3 + ), + '/Game/Characters/Cashew/S0/Ability_4/GameObject_Cashew_4_SonarPing.GameObject_Cashew_4_SonarPing_C': + (AgentType.tejo, 0), + '/Game/Characters/Cashew/S0/Ability_4/Pawn_Cashew_4_Spider_LockOn.Pawn_Cashew_4_Spider_LockOn_C': + (AgentType.tejo, 0), + '/Game/Characters/Cashew/S0/Ability_E/GameObject_Cashew_E_MapMissileMarker.GameObject_Cashew_E_MapMissileMarker_C': + (AgentType.tejo, 2), + '/Game/Characters/Cashew/S0/Ability_E/GameObject_Cashew_E_MapMissileMarker_SecondRocket.GameObject_Cashew_E_MapMissileMarker_SecondRocket_C': + (AgentType.tejo, 2), + '/Game/Characters/Cashew/S0/Ability_Q/Projectile_Cashew_Q_ShellShockGrenade.Projectile_Cashew_Q_ShellShockGrenade_C': + (AgentType.tejo, 1), + '/Game/Characters/Cashew/S0/Ability_Q/Projectile_Cashew_Q_ShellShockGrenade_Bounce.Projectile_Cashew_Q_ShellShockGrenade_Bounce_C': + (AgentType.tejo, 1), + '/Game/Characters/Cashew/S0/Ability_X/GameObject_Cashew_X_SegmentManager.GameObject_Cashew_X_SegmentManager_C': + (AgentType.tejo, 3), + '/Game/Characters/Clay/S0/Ability_4/Projectile_Clay_4_Projectile_Primary.Projectile_Clay_4_Projectile_Primary_C': + (AgentType.raze, 2), + '/Game/Characters/Clay/S0/Ability_E/Pawn_Clay_E_Boomba.Pawn_Clay_E_Boomba_C': + (AgentType.raze, 0), + '/Game/Characters/Clay/S0/Ability_Q/Projectile_Clay_Q_Satchel_Arming.Projectile_Clay_Q_Satchel_Arming_C': + (AgentType.raze, 1), + '/Game/Characters/Clay/S0/Ability_X/Projectile_Clay_X_Rocket.Projectile_Clay_X_Rocket_C': + (AgentType.raze, 3), + '/Game/Characters/Deadeye/S0/Ability_4/GameObject_Deadeye_E_Trap.GameObject_Deadeye_E_Trap_C': + (AgentType.chamber, 0), + '/Game/Characters/Deadeye/S0/Ability_4/Patch_Deadeye_E_Slow_Large.Patch_Deadeye_E_Slow_Large_C': + (AgentType.chamber, 0), + '/Game/Characters/Deadeye/S0/Ability_E/GameObject_Deadeye_E_Teleporter_Tether.GameObject_Deadeye_E_Teleporter_Tether_C': + (AgentType.chamber, 2), + '/Game/Characters/Grenadier/S0/Ability_4/Projectile_C_Grenadier_Flash.Projectile_C_Grenadier_Flash_C': + (AgentType.kayo, 1), + '/Game/Characters/Grenadier/S0/Ability_4/Projectile_C_Grenadier_Flash_Underhand.Projectile_C_Grenadier_Flash_Underhand_C': + (AgentType.kayo, 1), + '/Game/Characters/Grenadier/S0/Ability_E/Gameobject_Grenadier_E_SuppressionPulse.Gameobject_Grenadier_E_SuppressionPulse_C': + (AgentType.kayo, 2), + '/Game/Characters/Grenadier/S0/Ability_E/Projectile_Grenadier_E_SuppressionBlade.Projectile_Grenadier_E_SuppressionBlade_C': + (AgentType.kayo, 2), + '/Game/Characters/Guide/S0/Ability_4/GameObject_Guide_4_Heal_AOE.GameObject_Guide_4_Heal_AOE_C': + (AgentType.skye, 0), + '/Game/Characters/Guide/S0/Ability_E/GameObject_Guide_E_HawkFlash_FlashSource.GameObject_Guide_E_HawkFlash_FlashSource_C': + (AgentType.skye, 2), + '/Game/Characters/Guide/S0/Ability_E/Projectile_Guide_E_HawkFlash.Projectile_Guide_E_HawkFlash_C': + (AgentType.skye, 2), + '/Game/Characters/Guide/S0/Ability_Q/Pawn_Guide_Q_PossessableScout.Pawn_Guide_Q_PossessableScout_C': + (AgentType.skye, 1), + '/Game/Characters/Guide/S0/Ability_X/Pawn_Guide_X_Pack.Pawn_Guide_X_Pack_C': ( + AgentType.skye, + 3 + ), + '/Game/Characters/Gumshoe/S0/Ability_4/Projectile_Gumshoe_4_CageTrap.Projectile_Gumshoe_4_CageTrap_C': + (AgentType.cypher, 1), + '/Game/Characters/Gumshoe/S0/Ability_4/Zone_Gumshoe_4_Cage.Zone_Gumshoe_4_Cage_C': + (AgentType.cypher, 1), + '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire.GameObject_Gumshoe_E_TripWire_C': + (AgentType.cypher, 0), + '/Game/Characters/Gumshoe/S0/Ability_Q/GameObject_RemovableObject_GumshoeTrackingDart.GameObject_RemovableObject_GumshoeTrackingDart_C': + (AgentType.cypher, 2), + '/Game/Characters/Gumshoe/S0/Ability_Q/Pawn_Gumshoe_Q_PossessableCamera.Pawn_Gumshoe_Q_PossessableCamera_C': + (AgentType.cypher, 2), + '/Game/Characters/Hunter/S0/Ability_4/GameObject_Hunter_4_ExplosiveBolt_Explosion.GameObject_Hunter_4_ExplosiveBolt_Explosion_C': + (AgentType.sova, 1), + '/Game/Characters/Hunter/S0/Ability_E/Drone/GameObject_Hunter_E_Drone_RevealDart.GameObject_Hunter_E_Drone_RevealDart_C': + (AgentType.sova, 0), + '/Game/Characters/Hunter/S0/Ability_E/Drone/Pawn_Hunter_E_Drone.Pawn_Hunter_E_Drone_C': + (AgentType.sova, 0), + '/Game/Characters/Hunter/S0/Ability_Q/GameObject_Hunter_Q_SonarBolt.GameObject_Hunter_Q_SonarBolt_C': + (AgentType.sova, 2), + '/Game/Characters/Iris/S0/Ability_4/GameObject_Thumper_Concuss.GameObject_Thumper_Concuss_C': + (AgentType.miks, 0), + '/Game/Characters/Iris/S0/Ability_4/GameObject_Thumper_Heal.GameObject_Thumper_Heal_C': + (AgentType.miks, 1), + '/Game/Characters/Iris/S0/Ability_E/GameObject_Iris_E_Smoke.GameObject_Iris_E_Smoke_C': + (AgentType.miks, 2), + '/Game/Characters/Iris/S0/Ability_X/GameObject_Iris_X_SonicWave.GameObject_Iris_X_SonicWave_C': + (AgentType.miks, 4), + '/Game/Characters/Killjoy/S0/Ability_4/GameObject_Killjoy_4_BeeSwarm_Damage.GameObject_Killjoy_4_BeeSwarm_Damage_C': + (AgentType.killjoy, 0), + '/Game/Characters/Killjoy/S0/Ability_4/Projectile_Killjoy_4_RemoteBees_MultiDetonate.Projectile_Killjoy_4_RemoteBees_MultiDetonate_C': + (AgentType.killjoy, 0), + '/Game/Characters/Killjoy/S0/Ability_E/Pawn_Killjoy_E_Turret.Pawn_Killjoy_E_Turret_C': + (AgentType.killjoy, 2), + '/Game/Characters/Killjoy/S0/Ability_Q/Pawn_Killjoy_Q_StealthAlarmbot.Pawn_Killjoy_Q_StealthAlarmbot_C': + (AgentType.killjoy, 1), + '/Game/Characters/Killjoy/S0/Ability_X/GameObject_Killjoy_X_Bomb.GameObject_Killjoy_X_Bomb_C': + (AgentType.killjoy, 3), + '/Game/Characters/Nox/S0/Ability_4/GameObject_Nox_BarbedWire.GameObject_Nox_BarbedWire_C': + (AgentType.vyse, 1), + '/Game/Characters/Nox/S0/Ability_4/Patch_Nox_BarbedWire.Patch_Nox_BarbedWire_C': + (AgentType.vyse, 1), + '/Game/Characters/Nox/S0/Ability_E/GameObject_Nox_StealthingTrap_Flash_2.GameObject_Nox_StealthingTrap_Flash_2_C': + (AgentType.vyse, 2), + '/Game/Characters/Nox/S0/Ability_Q/GameObject_Nox_Wall.GameObject_Nox_Wall_C': + (AgentType.vyse, 0), + '/Game/Characters/Nox/S0/Ability_Q/GameObject_Nox_WallTrap.GameObject_Nox_WallTrap_C': + (AgentType.vyse, 0), + '/Game/Characters/Nox/S0/Ability_X/Gameobject_Nox_DisarmPulse.Gameobject_Nox_DisarmPulse_C': + (AgentType.vyse, 3), + '/Game/Characters/Phoenix/S0/Ability_4/Production/NewMolotov/Patch_Phoenix_MolotovFire.Patch_Phoenix_MolotovFire_C': + (AgentType.pheonix, 2), + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced.Projectile_Phoenix_E_FlareCurve_Synced_C': + (AgentType.pheonix, 1), + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced_Right.Projectile_Phoenix_E_FlareCurve_Synced_Right_C': + (AgentType.pheonix, 1), + '/Game/Characters/Phoenix/S0/Ability_Q/Production/GameObject_Phoenix_Q_FlameWallManager_Production.GameObject_Phoenix_Q_FlameWallManager_Production_C': + (AgentType.pheonix, 0), + '/Game/Characters/Phoenix/S0/Ability_X/Production/GameObject_Phoenix_X_ResTarget_Production.GameObject_Phoenix_X_ResTarget_Production_C': + (AgentType.pheonix, 3), + '/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/GameObject_Sarge_4_Smoke_ProductionNEW.GameObject_Sarge_4_Smoke_ProductionNEW_C': + (AgentType.brimstone, 2), + '/Game/Characters/Sarge/S0/Ability_Molotov/Patch_Sarge_Q_Molotov_Production.Patch_Sarge_Q_Molotov_Production_C': + (AgentType.brimstone, 1), + '/Game/Characters/Sarge/S0/Ability_OrbitalStrike/GameObject_Sarge_X_OrbitalStrike_Production.GameObject_Sarge_X_OrbitalStrike_Production_C': + (AgentType.brimstone, 3), + '/Game/Characters/Sarge/S0/Ability_SpeedStim/GameObject_Sarge_E_SpeedStim.GameObject_Sarge_E_SpeedStim_C': + (AgentType.brimstone, 0), + '/Game/Characters/Sequoia/S0/Ability_4/GameObject_Sequoia_4_MovingCover.GameObject_Sequoia_4_MovingCover_C': + (AgentType.iso, 0), + '/Game/Characters/Sequoia/S0/Ability_E/WreckingBall/GameObject_Sequoia_E_Orb.GameObject_Sequoia_E_Orb_C': + (AgentType.iso, 2), + '/Game/Characters/Sequoia/S0/Ability_Q/Projectile_Sequoia_Q_FragileMissile.Projectile_Sequoia_Q_FragileMissile_C': + (AgentType.iso, 1), + '/Game/Characters/Sequoia/S0/Ability_X/GameObject_Sequoia_X_LineCapture.GameObject_Sequoia_X_LineCapture_C': + (AgentType.iso, 3), + '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/GameObject_Smonk_NewSmoke.GameObject_Smonk_NewSmoke_C': + (AgentType.clove, 2), + '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/GameObject_Smonk_NewSmoke_PDS.GameObject_Smonk_NewSmoke_PDS_C': + (AgentType.clove, 2), + '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/GameObject_Smonk_Q_DecayExplosion.GameObject_Smonk_Q_DecayExplosion_C': + (AgentType.clove, 1), + '/Game/Characters/Sprinter/S0/Ability_4/GameObject_Sprinter_4_Tunnel.GameObject_Sprinter_4_Tunnel_C': + (AgentType.neon, 0), + '/Game/Characters/Sprinter/S0/Ability_Q/GameObject_Sprinter_Q_ElectricSphere.GameObject_Sprinter_Q_ElectricSphere_C': + (AgentType.neon, 1), + '/Game/Characters/Stealth/S0/Ability_4/Pawn_Stealth_4_Decoy_V2.Pawn_Stealth_4_Decoy_V2_C': + (AgentType.yoru, 0), + '/Game/Characters/Stealth/S0/Ability_E/Pawn_Stealth_E_TeleporterMoving_FakeTP.Pawn_Stealth_E_TeleporterMoving_FakeTP_C': + (AgentType.yoru, 2), + '/Game/Characters/Stealth/S0/Ability_E/Pawn_Stealth_E_TeleporterStationary_FakeTP.Pawn_Stealth_E_TeleporterStationary_FakeTP_C': + (AgentType.yoru, 2), + '/Game/Characters/Stealth/S0/Ability_Q/Projectile_Stealth_Q_BounceFlash.Projectile_Stealth_Q_BounceFlash_C': + (AgentType.yoru, 1), + '/Game/Characters/Thorne/S0/Ability_4/Patch_Thorne_4_SlowField_Production.Patch_Thorne_4_SlowField_Production_C': + (AgentType.sage, 1), + '/Game/Characters/Thorne/S0/Ability_E/GameObject_Thorne_E_Wall_Fortifying.GameObject_Thorne_E_Wall_Fortifying_C': + (AgentType.sage, 0), + '/Game/Characters/Vampire/S0/Ability_4/GameObject_Vampire_4_NearsightAOE_Source.GameObject_Vampire_4_NearsightAOE_Source_C': + (AgentType.reyna, 0), + '/Game/Characters/Wraith/S0/Ability_4/Zone_Wraith_4_Smoke.Zone_Wraith_4_Smoke_C': + (AgentType.omen, 2), + '/Game/Characters/Wraith/S0/Ability_Q/Projectile_Wraith_Q_NearsightMissile.Projectile_Wraith_Q_NearsightMissile_C': + (AgentType.omen, 1), + '/Game/Characters/Wraith/S0/Ability_X/Intention_Wraith_X_GlobalTeleport.Intention_Wraith_X_GlobalTeleport_C': + (AgentType.omen, 3), + '/Game/Characters/Wushu/S0/Ability_4/GameObject_Wushu_4_SmokeZone.GameObject_Wushu_4_SmokeZone_C': + (AgentType.jett, 0), +}; + +const observedIgnored = [ + '/Game/Characters/AggroBot/S0/Ability_4/Ability_Aggrobot_C_ExplodeyPatch.Ability_Aggrobot_C_ExplodeyPatch_C', + '/Game/Characters/AggroBot/S0/Ability_4/Projectile_Aggrobot_C_ExplodeyPatch.Projectile_Aggrobot_C_ExplodeyPatch_C', + '/Game/Characters/AggroBot/S0/Ability_E/Ability_E_Aggrobot_DiscTurret.Ability_E_Aggrobot_DiscTurret_C', + '/Game/Characters/AggroBot/S0/Ability_E/Projectile_Aggrobot_Zamboni_Rocket.Projectile_Aggrobot_Zamboni_Rocket_C', + '/Game/Characters/AggroBot/S0/Ability_E/Projectile_E_Aggrobot_OrbSpawner.Projectile_E_Aggrobot_OrbSpawner_C', + '/Game/Characters/AggroBot/S0/Ability_Q/Ability_Q_Aggrobot_SeekerNade.Ability_Q_Aggrobot_SeekerNade_C', + '/Game/Characters/AggroBot/S0/Ability_X/Ability_Aggrobot_X_RollyExplosion.Ability_Aggrobot_X_RollyExplosion_C', + '/Game/Characters/AggroBot/S0/Ability_X/Ability_Pawn_Aggrobot_RollyExplodey.Ability_Pawn_Aggrobot_RollyExplodey_C', + '/Game/Characters/AggroBot/S0/ReclaimOrbs/GameObject_Aggrobot_Reclaim_Orb_ExplodeyPatch.GameObject_Aggrobot_Reclaim_Orb_ExplodeyPatch_C', + '/Game/Characters/AggroBot/S0/ReclaimOrbs/GameObject_Aggrobot_Reclaim_Orb_SeekerNade.GameObject_Aggrobot_Reclaim_Orb_SeekerNade_C', + '/Game/Characters/AggroBot/S0/ReclaimOrbs/GameObject_Aggrobot_Reclaim_Orb_SeekerNadePlantSuccessful.GameObject_Aggrobot_Reclaim_Orb_SeekerNadePlantSuccessful_C', + '/Game/Characters/AggroBot/S0/ReclaimOrbs/GameObject_Aggrobot_Reclaim_Orb_Turret.GameObject_Aggrobot_Reclaim_Orb_Turret_C', + '/Game/Characters/AggroBot/S0/ReclaimOrbs/GameObject_Aggrobot_X_Reclaim_Orb.GameObject_Aggrobot_X_Reclaim_Orb_C', + '/Game/Characters/BountyHunter/S0/Ability_4/Ability_BountyHunter_4_WolfHoundBendable.Ability_BountyHunter_4_WolfHoundBendable_C', + '/Game/Characters/BountyHunter/S0/Ability_E/Ability_E_BountyHunter_ReconDivebomb.Ability_E_BountyHunter_ReconDivebomb_C', + '/Game/Characters/BountyHunter/S0/Ability_Q/Ability_Q_BountyHunter_TetherDivebomb.Ability_Q_BountyHunter_TetherDivebomb_C', + '/Game/Characters/BountyHunter/S0/Ability_Q/Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion.Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion_C', + '/Game/Characters/BountyHunter/S0/Ability_X/Ability_BountyHunter_X_WaveForm.Ability_BountyHunter_X_WaveForm_C', + '/Game/Characters/BountyHunter/S0/Global/Trails/Path_BountyHunter_Trail.Path_BountyHunter_Trail_C', + '/Game/Characters/Breach/S0/Ability_4/Ability_Breach_4_FusionBlast.Ability_Breach_4_FusionBlast_C', + '/Game/Characters/Breach/S0/Ability_4/Projectile_Breach_4_FusionBlast.Projectile_Breach_4_FusionBlast_C', + '/Game/Characters/Breach/S0/Ability_E/Ability_Breach_E_Fissure.Ability_Breach_E_Fissure_C', + '/Game/Characters/Breach/S0/Ability_Q/Ability_Breach_Q_Flash.Ability_Breach_Q_Flash_C', + '/Game/Characters/Breach/S0/Ability_X/Ability_Breach_X_Shockwave.Ability_Breach_X_Shockwave_C', + '/Game/Characters/Cable/S0/Ability_4/Ability_Cable_4_NetToss.Ability_Cable_4_NetToss_C', + '/Game/Characters/Cable/S0/Ability_4/NetTossRemovableDebuff.NetTossRemovableDebuff_C', + '/Game/Characters/Cable/S0/Ability_4/Projectile_NetToss.Projectile_NetToss_C', + '/Game/Characters/Cable/S0/Ability_E/Ability_Cable_E_CableJam.Ability_Cable_E_CableJam_C', + '/Game/Characters/Cable/S0/Ability_E/GameObject_CableJam_CableDeployer_Precomputed.GameObject_CableJam_CableDeployer_Precomputed_C', + '/Game/Characters/Cable/S0/Ability_E/Projectile_CableJam_InAir.Projectile_CableJam_InAir_C', + '/Game/Characters/Cable/S0/Ability_Q/Ability_Cable_Q_SoundSensor.Ability_Cable_Q_SoundSensor_C', + '/Game/Characters/Cable/S0/Ability_X/Ability_Cable_X_FishingHook.Ability_Cable_X_FishingHook_C', + '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_BouncingTrajectoryWarning.GameObject_FishingHook_BouncingTrajectoryWarning_C', + '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_CageSphere.GameObject_FishingHook_CageSphere_C', + '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_EndOfTrajectoryWarning.GameObject_FishingHook_EndOfTrajectoryWarning_C', + '/Game/Characters/Cable/S0/Ability_X/GameObject_MotherNode.GameObject_MotherNode_C', + '/Game/Characters/Cable/S0/Ability_X/GameObject_Spline.GameObject_Spline_C', + '/Game/Characters/Cashew/S0/Ability_4/Ability_Cashew_4_Spider_Abilities_SelfDestruct.Ability_Cashew_4_Spider_Abilities_SelfDestruct_C', + '/Game/Characters/Cashew/S0/Ability_4/Ability_Cashew_4_Spider_LockOn.Ability_Cashew_4_Spider_LockOn_C', + '/Game/Characters/Cashew/S0/Ability_E/AIPawn_Cashew_E_SeekingTargetMissile.AIPawn_Cashew_E_SeekingTargetMissile_C', + '/Game/Characters/Cashew/S0/Ability_E/Ability_Cashew_E_Airstrike.Ability_Cashew_E_Airstrike_C', + '/Game/Characters/Cashew/S0/Ability_E/GameObject_Cashew_E_AirStrikeMortar.GameObject_Cashew_E_AirStrikeMortar_C', + '/Game/Characters/Cashew/S0/Ability_Q/Ability_Cashew_Q_ShellShockGrenade.Ability_Cashew_Q_ShellShockGrenade_C', + '/Game/Characters/Cashew/S0/Ability_X/Ability_Cashew_X_Airstrike.Ability_Cashew_X_Airstrike_C', + '/Game/Characters/Cashew/S0/Ability_X/GameObject_Cashew_X_Segment.GameObject_Cashew_X_Segment_C', + '/Game/Characters/Clay/S0/Ability_4/Ability_Clay_4_ClusterGrenade.Ability_Clay_4_ClusterGrenade_C', + '/Game/Characters/Clay/S0/Ability_4/Projectile_Clay_4_Projectile_Secondary.Projectile_Clay_4_Projectile_Secondary_C', + '/Game/Characters/Clay/S0/Ability_4/Projectile_Clay_4_Projectile_SecondarySpawner.Projectile_Clay_4_Projectile_SecondarySpawner_C', + '/Game/Characters/Clay/S0/Ability_E/Ability_Clay_E_Boomba.Ability_Clay_E_Boomba_C', + '/Game/Characters/Clay/S0/Ability_Q/Ability_Clay_Q_Satchel.Ability_Clay_Q_Satchel_C', + '/Game/Characters/Clay/S0/Ability_Q/GameObject_Clay_Q_Explosion.GameObject_Clay_Q_Explosion_C', + '/Game/Characters/Clay/S0/Ability_X/Ability_Clay_X_RocketLauncher.Ability_Clay_X_RocketLauncher_C', + '/Game/Characters/Deadeye/S0/Ability_4/Ability_Deadeye_4_Trap.Ability_Deadeye_4_Trap_C', + '/Game/Characters/Deadeye/S0/Ability_4/Projectile_Deadeye_4_Trap_Dart.Projectile_Deadeye_4_Trap_Dart_C', + '/Game/Characters/Deadeye/S0/Ability_E/Ability_Deadeye_E_Teleporter_Tethers.Ability_Deadeye_E_Teleporter_Tethers_C', + '/Game/Characters/Deadeye/S0/Ability_Q/Ability_Deadeye_Q_Pistol.Ability_Deadeye_Q_Pistol_C', + '/Game/Characters/Deadeye/S0/Ability_Q/Gun/Gun_Deadeye_Q_Pistol.Gun_Deadeye_Q_Pistol_C', + '/Game/Characters/Deadeye/S0/Ability_X/Ability_Deadeye_X_Giantslayer_Prototype_DanPrototype.Ability_Deadeye_X_Giantslayer_Prototype_DanPrototype_C', + '/Game/Characters/Deadeye/S0/Ability_X/Gun_Giantslayer/Gun_Deadeye_X_Giantslayer_Prototype_FIreRatePrototype.Gun_Deadeye_X_Giantslayer_Prototype_FireRatePrototype_C', + '/Game/Characters/Grenadier/S0/Ability_4/Ability_Grenadier_C_Flash.Ability_Grenadier_C_Flash_C', + '/Game/Characters/Grenadier/S0/Ability_E/Ability_E_Grenadier_EMPKnife.Ability_E_Grenadier_EMPKnife_C', + '/Game/Characters/Grenadier/S0/Ability_Q/Ability_Grenadier_Q_BasicSemtex.Ability_Grenadier_Q_BasicSemtex_C', + '/Game/Characters/Grenadier/S0/Ability_X/Ability_Grenadier_X_OverloadPulse.Ability_Grenadier_X_OverloadPulse_C', + '/Game/Characters/Grenadier/S0/Ability_X/Grenadier_Recharge_Usable.Grenadier_Recharge_Usable_C', + '/Game/Characters/Guide/S0/Ability_4/Ability_Guide_4_Heal.Ability_Guide_4_Heal_C', + '/Game/Characters/Guide/S0/Ability_E/Ability_Guide_E_HawkFlash.Ability_Guide_E_HawkFlash_C', + '/Game/Characters/Guide/S0/Ability_Q/Ability_Guide_Q_PossessableScout.Ability_Guide_Q_PossessableScout_C', + '/Game/Characters/Guide/S0/Ability_Q/Ability_Guide_Q_PossessableScout_ScoutAbilities.Ability_Guide_Q_PossessableScout_ScoutAbilities_C', + '/Game/Characters/Guide/S0/Ability_X/Ability_Guide_X_Pack.Ability_Guide_X_Pack_C', + '/Game/Characters/Guide/S0/Ability_X/Equippable_Guide_X_Pack_Attack.Equippable_Guide_X_Pack_Attack_C', + '/Game/Characters/Guide/S0/Ability_X/Equippable_PlaceholderDefault.Equippable_PlaceholderDefault_C', + '/Game/Characters/Gumshoe/S0/Ability_4/Ability_Gumshoe_4_CageTrap.Ability_Gumshoe_4_CageTrap_C', + '/Game/Characters/Gumshoe/S0/Ability_E/Ability_Gumshoe_E_TripWire.Ability_Gumshoe_E_TripWire_C', + '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire_SecondWire.GameObject_Gumshoe_E_TripWire_SecondWire_C', + '/Game/Characters/Gumshoe/S0/Ability_Q/Ability_Gumshoe_Q_Camera.Ability_Gumshoe_Q_Camera_C', + '/Game/Characters/Gumshoe/S0/Ability_Q/Ability_Gumshoe_Q_Camera_Dart.Ability_Gumshoe_Q_Camera_Dart_C', + '/Game/Characters/Gumshoe/S0/Ability_Q/Ability_Gumshoe_Q_UnpossessCamera.Ability_Gumshoe_Q_UnpossessCamera_C', + '/Game/Characters/Gumshoe/S0/Ability_Q/Projectile_Gumshoe_Q_CameraTrackingDart.Projectile_Gumshoe_Q_CameraTrackingDart_C', + '/Game/Characters/Gumshoe/S0/Ability_X/Ability_Gumshoe_X_InterrogateV2.Ability_Gumshoe_X_InterrogateV2_C', + '/Game/Characters/Hunter/S0/Ability_4/Ability_Hunter_4_BoltExplosive.Ability_Hunter_4_BoltExplosive_C', + '/Game/Characters/Hunter/S0/Ability_4/Projectile_Hunter_4_ExplosiveBolt.Projectile_Hunter_4_ExplosiveBolt_C', + '/Game/Characters/Hunter/S0/Ability_E/Drone/Ability_Hunter_E_DeployDrone.Ability_Hunter_E_DeployDrone_C', + '/Game/Characters/Hunter/S0/Ability_E/Drone/Ability_Hunter_E_Drone_Abilities.Ability_Hunter_E_Drone_Abilities_C', + '/Game/Characters/Hunter/S0/Ability_Q/Ability_Hunter_Q_RevealBolt_Signature.Ability_Hunter_Q_RevealBolt_Signature_C', + '/Game/Characters/Hunter/S0/Ability_Q/GameObject_Hunter_Q_SonarPing.GameObject_Hunter_Q_SonarPing_C', + '/Game/Characters/Hunter/S0/Ability_Q/Projectile_Hunter_Q_RevealBolt.Projectile_Hunter_Q_RevealBolt_C', + '/Game/Characters/Hunter/S0/Ability_X/Ability_Hunter_X_LaserMulti.Ability_Hunter_X_LaserMulti_C', + '/Game/Characters/Iris/S0/Ability_4/Ability_Iris_Thumper.Ability_Iris_Thumper_C', + '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Concuss.Projectile_Thumper_Concuss_C', + '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Heal.Projectile_Thumper_Heal_C', + '/Game/Characters/Iris/S0/Ability_E/Ability_Iris_E_MT_Smoke_Production.Ability_Iris_E_MT_Smoke_Production_C', + '/Game/Characters/Iris/S0/Ability_Q/Ability_Iris_Harmonize.Ability_Iris_Harmonize_C', + '/Game/Characters/Iris/S0/Ability_X/Ability_Iris_X_SonicWave.Ability_Iris_X_SonicWave_C', + '/Game/Characters/Killjoy/S0/Ability_4/Ability_Killjoy_4_RemoteBees_MultiDetonate.Ability_Killjoy_4_RemoteBees_MultiDetonate_C', + '/Game/Characters/Killjoy/S0/Ability_E/Ability_Killjoy_E_Turret.Ability_Killjoy_E_Turret_C', + '/Game/Characters/Killjoy/S0/Ability_E/Ability_Killjoy_E_TurretAttack.Ability_Killjoy_E_TurretAttack_C', + '/Game/Characters/Killjoy/S0/Ability_Q/Ability_Killjoy_Q_Alarmbot.Ability_Killjoy_Q_Alarmbot_C', + '/Game/Characters/Killjoy/S0/Ability_X/Ability_Killjoy_X_Bomb.Ability_Killjoy_X_Bomb_C', + '/Game/Characters/Killjoy/S0/Ability_X/GameObject_Killjoy_X_Shockwave.GameObject_Killjoy_X_Shockwave_C', + '/Game/Characters/Nox/S0/Ability_4/Ability_Nox_BarbedWire.Ability_Nox_BarbedWire_C', + '/Game/Characters/Nox/S0/Ability_4/Projectile_Nox_BarbedWire.Projectile_Nox_BarbedWire_C', + '/Game/Characters/Nox/S0/Ability_E/Ability_Nox_FlashTrap.Ability_Nox_FlashTrap_C', + '/Game/Characters/Nox/S0/Ability_Q/Ability_Nox_Wall.Ability_Nox_Wall_C', + '/Game/Characters/Nox/S0/Ability_X/Ability_Nox_DisarmPulse.Ability_Nox_DisarmPulse_C', + '/Game/Characters/Phoenix/S0/Ability_4/Production/Ability_Phoenix_4_Molotov_Production.Ability_Phoenix_4_Molotov_Production_C', + '/Game/Characters/Phoenix/S0/Ability_4/Production/Projectile_Phoenix_4_Molotov_Production.Projectile_Phoenix_4_Molotov_Production_C', + '/Game/Characters/Phoenix/S0/Ability_E/Production/Ability_Phoenix_E_FlareCurve_Production.Ability_Phoenix_E_FlareCurve_Production_C', + '/Game/Characters/Phoenix/S0/Ability_Q/Production/Ability_Phoenix_Q_FireballWall_Production.Ability_Phoenix_Q_FireballWall_Production_C', + '/Game/Characters/Phoenix/S0/Ability_Q/Production/Projectile_Phoenix_Q_FlameWall_ThroughWall.Projectile_Phoenix_Q_FlameWall_ThroughWall_C', + '/Game/Characters/Phoenix/S0/Ability_X/Production/Ability_Phoenix_X_SelfRes_Production.Ability_Phoenix_X_SelfRes_Production_C', + '/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/Ability_Sarge_4_MapTargetSmoke_Production.Ability_Sarge_4_MapTargetSmoke_Production_C', + '/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/GameObject_Sarge_4_SmokeManager_Production.GameObject_Sarge_4_SmokeManager_Production_C', + '/Game/Characters/Sarge/S0/Ability_Molotov/Ability_Sarge_Q_Molotov_Production.Ability_Sarge_Q_Molotov_Production_C', + '/Game/Characters/Sarge/S0/Ability_Molotov/Projectile_Sarge_Q_Molotov_Production.Projectile_Sarge_Q_Molotov_Production_C', + '/Game/Characters/Sarge/S0/Ability_OrbitalStrike/Ability_Sarge_X_OrbitalStrike.Ability_Sarge_X_OrbitalStrike_C', + '/Game/Characters/Sarge/S0/Ability_SpeedStim/Ability_Sarge_E_SpeedStim.Ability_Sarge_E_SpeedStim_C', + '/Game/Characters/Sarge/S0/Ability_SpeedStim/Projectile_Sarge_E_SpeedStim.Projectile_Sarge_E_SpeedStim_C', + '/Game/Characters/Sequoia/S0/Ability_4/Ability_Sequoia_4_Wave.Ability_Sequoia_4_Wave_C', + '/Game/Characters/Sequoia/S0/Ability_E/WreckingBall/Ability_Sequoia_E_WreckingBall.Ability_Sequoia_E_WreckingBall_C', + '/Game/Characters/Sequoia/S0/Ability_E/WreckingBall/GameObject_Sequoia_E_Shield.GameObject_Sequoia_E_Shield_C', + '/Game/Characters/Sequoia/S0/Ability_Q/Ability_Sequoia_Q_FragileMissilePrototypeEquipped.Ability_Sequoia_Q_FragileMissilePrototypeEquipped_C', + '/Game/Characters/Sequoia/S0/Ability_Q/GameObject_Sequoia_Q_FragileMissile_TrajectoryWarning.GameObject_Sequoia_Q_FragileMissile_TrajectoryWarning_C', + '/Game/Characters/Sequoia/S0/Ability_X/Ability_X_Sequoia_ArenaLineCapture.Ability_X_Sequoia_ArenaLineCapture_C', + '/Game/Characters/Sequoia/S0/Ability_X/Actor_Sequoia_X_StandardArena.Actor_Sequoia_X_StandardArena_C', + '/Game/Characters/Sequoia/S0/Ability_X/Equippable_Sequoia_X_ArenaTeleport.Equippable_Sequoia_X_ArenaTeleport_C', + '/Game/Characters/Smonk/S0/Ability_4/ReactiveArmor/Ability_Smonk_BFArmor_Reactive.Ability_Smonk_BFArmor_Reactive_C', + '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/Ability_Smonk_E_MapTargetSmokeV2.Ability_Smonk_E_MapTargetSmokeV2_C', + '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/Ability_Smonk_E_PostDeath.Ability_Smonk_E_PostDeath_C', + '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/Ability_Q_Smonk_DebuffKnife.Ability_Q_Smonk_DebuffKnife_C', + '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/Projectile_Smonk_DecayNade.Projectile_Smonk_DecayNade_C', + '/Game/Characters/Smonk/S0/Ability_X/ReactiveRes/Ability_Smonk_X_PostDeath_ReactiveResStart.Ability_Smonk_X_PostDeath_ReactiveResStart_C', + '/Game/Characters/Smonk/S0/Ability_X/ReactiveRes/Ability_Smonk_X_ReactiveRes_Engineering.Ability_Smonk_X_ReactiveRes_Engineering_C', + '/Game/Characters/Smonk/S0/Global/PostDeath/Equippable_Smonk_PostDeath_Unarmed.Equippable_Smonk_PostDeath_Unarmed_C', + '/Game/Characters/Sprinter/S0/Ability_4/Ability_Sprinter_4_Tunnel_Production.Ability_Sprinter_4_Tunnel_Production_C', + '/Game/Characters/Sprinter/S0/Ability_4/Projectile_Neon_C_Tunnel.Projectile_Neon_C_Tunnel_C', + '/Game/Characters/Sprinter/S0/Ability_4/Projectile_Neon_C_Tunnel_Cosmetic.Projectile_Neon_C_Tunnel_Cosmetic_C', + '/Game/Characters/Sprinter/S0/Ability_E/Ability_Sprinter_E_Sprint_Production.Ability_Sprinter_E_Sprint_Production_C', + '/Game/Characters/Sprinter/S0/Ability_Q/Ability_Sprinter_Q_GroundStrike_Production.Ability_Sprinter_Q_GroundStrike_Production_C', + '/Game/Characters/Sprinter/S0/Ability_Q/Projectile_Sprinter_4_GroundStrike.Projectile_Sprinter_4_GroundStrike_C', + '/Game/Characters/Sprinter/S0/Ability_X/Ability_Sprinter_X_LightningGun_Production.Ability_Sprinter_X_LightningGun_Production_C', + '/Game/Characters/Sprinter/S0/Ability_X/Gun_Sprinter_X_HeavyLightningGun_Production.Gun_Sprinter_X_HeavyLightningGun_Production_C', + '/Game/Characters/Stealth/S0/Ability_4/Ability_Stealth_4_Decoy.Ability_Stealth_4_Decoy_C', + '/Game/Characters/Stealth/S0/Ability_4/GameObject_Stealth_DecoySpawner.GameObject_Stealth_DecoySpawner_C', + '/Game/Characters/Stealth/S0/Ability_E/Ability_Stealth_E_Teleport.Ability_Stealth_E_Teleport_C', + '/Game/Characters/Stealth/S0/Ability_Q/Ability_Stealth_Q_BounceFlash.Ability_Stealth_Q_BounceFlash_C', + '/Game/Characters/Stealth/S0/Ability_X/Ability_Stealth_X_Cloak_Equip.Ability_Stealth_X_Cloak_Equip_C', + '/Game/Characters/Thorne/S0/Ability_4/Ability_Thorne_4_SlowField_Production.Ability_Thorne_4_SlowField_Production_C', + '/Game/Characters/Thorne/S0/Ability_4/Projectile_Thorne_4_SlowFIeld_Production.Projectile_Thorne_4_SlowFIeld_Production_C', + '/Game/Characters/Thorne/S0/Ability_E/Ability_Thorne_E_Wall_Fortifying.Ability_Thorne_E_Wall_Fortifying_C', + '/Game/Characters/Thorne/S0/Ability_E/GameObject_Thorne_E_Wall_Segment_Fortifying.GameObject_Thorne_E_Wall_Segment_Fortifying_C', + '/Game/Characters/Thorne/S0/Ability_Q/Ability_Thorne_Q_Heal_Production_New.Ability_Thorne_Q_Heal_Production_New_C', + '/Game/Characters/Thorne/S0/Ability_X/Ability_Thorne_X_Resurrect_Production.Ability_Thorne_X_Resurrect_Production_C', + '/Game/Characters/Vampire/S0/Ability_4/Ability_Vampire_4_NearsightAoE.Ability_Vampire_4_NearsightAoE_C', + '/Game/Characters/Vampire/S0/Ability_4/Projectile_Vampire_4_NearsightAoE.Projectile_Vampire_4_NearsightAoE_C', + '/Game/Characters/Vampire/S0/Ability_E/Ability_Vampire_E_Escape.Ability_Vampire_E_Escape_C', + '/Game/Characters/Vampire/S0/Ability_Q/Ability_Vampire_Q_Heal.Ability_Vampire_Q_Heal_C', + '/Game/Characters/Vampire/S0/Ability_Q/GameObject_Vampire_Q_Heal_HealPool_AutoActivate.GameObject_Vampire_Q_Heal_HealPool_AutoActivate_C', + '/Game/Characters/Vampire/S0/Ability_Q/GameObject_Vampire_Q_Heal_HealPool_High.GameObject_Vampire_Q_Heal_HealPool_High_C', + '/Game/Characters/Vampire/S0/Ability_X/Ability_Vampire_X_Frenzy.Ability_Vampire_X_Frenzy_C', + '/Game/Characters/Wraith/S0/Ability_4/Ability_Wraith_4_Smoke.Ability_Wraith_4_Smoke_C', + '/Game/Characters/Wraith/S0/Ability_4/Projectile_Wraith_4_Smoke.Projectile_Wraith_4_Smoke_C', + '/Game/Characters/Wraith/S0/Ability_E/Ability_Wraith_E_ShortTeleport.Ability_Wraith_E_ShortTeleport_C', + '/Game/Characters/Wraith/S0/Ability_Q/Ability_Wraith_Q_NearsightMissile.Ability_Wraith_Q_NearsightMissile_C', + '/Game/Characters/Wraith/S0/Ability_Q/GameObject_Wraith_Q_NearsightMissile_TrajectoryWarning.GameObject_Wraith_Q_NearsightMissile_TrajectoryWarning_C', + '/Game/Characters/Wraith/S0/Ability_X/Ability_Wraith_X_GlobalTeleport.Ability_Wraith_X_GlobalTeleport_C', + '/Game/Characters/Wushu/S0/Ability_4/Ability_Wushu_4_Smoke.Ability_Wushu_4_Smoke_C', + '/Game/Characters/Wushu/S0/Ability_4/Projectile_Wushu_4_Smoke.Projectile_Wushu_4_Smoke_C', + '/Game/Characters/Wushu/S0/Ability_E/Ability_Wushu_E_Dash.Ability_Wushu_E_Dash_C', + '/Game/Characters/Wushu/S0/Ability_Q/Ability_Wushu_Q_CycloneBoost.Ability_Wushu_Q_CycloneBoost_C', + '/Game/Characters/Wushu/S0/Ability_X/Ability_Wushu_X_Dagger_Production.Ability_Wushu_X_Dagger_Production_C', + '/Game/Characters/Wushu/S0/Glide/Ability_Wushu_Passive_Glide.Ability_Wushu_Passive_Glide_C', +]; diff --git a/test/replay_document_test.dart b/test/replay_document_test.dart new file mode 100644 index 000000000..8a8eafc9c --- /dev/null +++ b/test/replay_document_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/replay/replay_document.dart'; + +/// Builds an `.icrp` buffer the way `native/replay` lays it out. +Uint8List icrp(Map json, + {List> records = const []}) { + final jsonBytes = utf8.encode(jsonEncode(json)); + final blob = ByteData(records.length * 24); + for (var i = 0; i < records.length; i++) { + final r = records[i]; + blob.setInt32(i * 24, r[0].toInt(), Endian.little); + for (var f = 0; f < 5; f++) { + blob.setFloat32(i * 24 + 4 + f * 4, r[f + 1].toDouble(), Endian.little); + } + } + final blobStart = (12 + jsonBytes.length + 7) & ~7; + final bytes = Uint8List(blobStart + blob.lengthInBytes); + final header = ByteData.sublistView(bytes); + bytes.setAll(0, ascii.encode('ICRP')); + header.setUint32(4, ReplayDocument.formatVersion, Endian.little); + header.setUint32(8, jsonBytes.length, Endian.little); + bytes.setAll(12, jsonBytes); + bytes.setAll(blobStart, blob.buffer.asUint8List()); + return bytes; +} + +Map sampleJson({Map? movement}) => { + 'decoder': {'name': 'icarus_replay', 'version': 'test'}, + 'match': { + 'id': 'match', + 'mapPath': '/Game/Maps/Juliett/Juliett', + 'build': '++Ares-Core+release-13.00', + 'durationMs': 100000, + 'recordedAt': '2026-07-10T03:35:45Z', + }, + 'players': [ + { + 'subject': 'a', + 'agentId': 'ADD6443A-41BD-E414-F6AD-E58D267F4E95', + 'team': 'Red' + }, + { + 'subject': 'b', + 'agentId': '8e253930-4c05-31dd-1b6c-968525494517', + 'team': 'Blue' + }, + ], + 'rounds': [ + { + 'index': 0, + 'startMs': 1000, + 'combatStartMs': 31000, + 'endMs': 60000, + 'attackingTeam': 'Red', + 'winningTeam': 'Blue', + 'endReason': 'defused', + 'economy': [] + }, + { + 'index': 1, + 'startMs': 60000, + 'endMs': 100000, + 'attackingTeam': 'Red', + 'economy': [ + {'subject': 'a', 'credits': 900, 'loadoutValue': 3900, 'armor': 50}, + ] + }, + ], + 'kills': [ + {'timeMs': 50000, 'victim': 'a', 'killer': 'b', 'assists': []}, + {'timeMs': 40000, 'victim': 'b', 'killer': 'a'}, + ], + 'vitals': { + 'a': [ + [1000, 100, 0], + [45000, 40, 0], + [50000, 0, 0], + [60000, 100, 50] + ], + }, + 'utility': [], + 'casts': [], + 'movement': movement ?? {}, + 'quality': {'transformVerified': true, 'decodeErrors': 0}, + }; + +void main() { + test('reads the container, players, rounds and kills', () { + final document = ReplayDocument.fromBytes(icrp(sampleJson())); + expect(document.match.mapPath, '/Game/Maps/Juliett/Juliett'); + expect(document.match.recordedAt, DateTime.utc(2026, 7, 10, 3, 35, 45)); + expect( + document.players.first.agentId, 'add6443a-41bd-e414-f6ad-e58d267f4e95'); + expect(document.players.first.team, ReplayTeam.red); + expect(document.rounds.first.endReason, ReplayRoundEnd.defused); + expect(document.rounds.first.playStartMs, 31000); + expect(document.rounds.last.playStartMs, 60000); + expect(document.rounds.last.economyFor('a')?.armor, 50); + expect(document.kills.map((kill) => kill.timeMs), [40000, 50000]); + expect(document.quality.transformVerified, isTrue); + }); + + test('rejects other files and other format versions', () { + expect( + () => ReplayDocument.fromBytes( + Uint8List.fromList(utf8.encode('not a replay at all'))), + throwsFormatException, + ); + final bytes = icrp(sampleJson()); + ByteData.sublistView(bytes).setUint32(4, 99, Endian.little); + expect(() => ReplayDocument.fromBytes(bytes), throwsFormatException); + expect( + () => ReplayDocument.fromBytes(Uint8List.sublistView(bytes, 0, 20)), + throwsFormatException, + ); + }); + + test('finds the round at a time', () { + final document = ReplayDocument.fromBytes(icrp(sampleJson())); + expect(document.roundAt(0)?.index, 0); + expect(document.roundAt(59999)?.index, 0); + expect(document.roundAt(60000)?.index, 1); + }); + + test('vitals hold their last value', () { + final vitals = ReplayDocument.fromBytes(icrp(sampleJson())).vitals['a']!; + expect(vitals.at(500), isNull); + expect(vitals.at(44999)?.health, 100); + expect(vitals.at(45000)?.health, 40); + expect(vitals.at(55000)?.health, 0); + expect(vitals.at(61000)?.armor, 50); + }); + + group('movement', () { + ReplayMovementTrack track() => ReplayDocument.fromBytes(icrp( + sampleJson(movement: { + 'a': {'offset': 0, 'count': 4}, + }), + records: [ + [1000, 0, 0, 0, 350, 0], + [1100, 100, 0, 0, 10, 10], + [1200, 200, 0, 0, 10, 10], + // A long gap: the player was dead. + [5000, 900, 900, 0, 90, 0], + ], + )).movement['a']!; + + test('interpolates position and takes the short way round on yaw', () { + final pose = track().poseAt(1050)!; + expect(pose.position.x, closeTo(50, 1e-3)); + expect(pose.yaw, closeTo(360, 1e-3)); + expect(pose.pitch, closeTo(5, 1e-3)); + }); + + test('invents nothing across a long gap or outside the track', () { + final movement = track(); + expect(movement.poseAt(999), isNull); + expect(movement.poseAt(1300)?.position.x, closeTo(200, 1e-3)); + expect(movement.poseAt(3000), isNull); + expect(movement.poseAt(5000)?.position.y, closeTo(900, 1e-3)); + expect(movement.poseAt(6000), isNull); + }); + + test('rejects a record range outside the blob', () { + expect( + () => ReplayDocument.fromBytes(icrp( + sampleJson(movement: { + 'a': {'offset': 0, 'count': 5}, + }), + records: [ + [0, 0, 0, 0, 0, 0], + ], + )), + throwsFormatException, + ); + }); + }); +} diff --git a/test/replay_files_test.dart b/test/replay_files_test.dart new file mode 100644 index 000000000..6f19c2ff5 --- /dev/null +++ b/test/replay_files_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/replay/replay_files.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + late Directory support; + late Directory demos; + late ReplayFiles files; + + setUp(() async { + root = await Directory.systemTemp.createTemp('icarus-replay-files'); + support = await Directory(p.join(root.path, 'support')).create(); + demos = await Directory(p.join(root.path, 'VALORANT', 'Saved', 'Demos')) + .create(recursive: true); + files = ReplayFiles( + supportDirectory: () async => support, + valorantDemosPath: demos.path, + ); + }); + + tearDown(() => root.delete(recursive: true)); + + Future writeReplay(Directory directory, String matchId) => + File(p.join(directory.path, '$matchId.vrf')).writeAsBytes([1, 2, 3]); + + test('lists replays from both folders once, preferring the kept copy', + () async { + await writeReplay(demos, 'a'); + await writeReplay(demos, 'b'); + await File(p.join(demos.path, 'notes.txt')).writeAsString('x'); + final kept = await files.import(p.join(demos.path, 'b.vrf')); + + final listed = await files.list(); + expect(listed.map((file) => file.matchId).toSet(), {'a', 'b'}); + final b = listed.firstWhere((file) => file.matchId == 'b'); + expect(b.source, ReplaySource.icarus); + expect(b.path, kept.path); + expect( + listed.firstWhere((file) => file.matchId == 'a').source, + ReplaySource.valorant, + ); + }); + + test('import leaves no partial file and is idempotent', () async { + final source = await writeReplay(root, 'c'); + final first = await files.import(source.path); + final second = await files.import(source.path); + expect(second.path, first.path); + final names = (await (await files.icarusReplaysDirectory()).list().toList()) + .map((entity) => p.basename(entity.path)); + expect(names, ['c.vrf']); + }); + + test('cache is keyed by file and decoder version, and replaces old ones', + () async { + await writeReplay(demos, 'd'); + final file = (await files.list()).single; + expect(await files.readCache(file, 'v1'), isNull); + + await files.writeCache(file, 'v1', Uint8List.fromList([1])); + expect(await files.readCache(file, 'v1'), [1]); + expect(await files.readCache(file, 'v2'), isNull); + + await files.writeCache(file, 'v2', Uint8List.fromList([2])); + expect(await files.readCache(file, 'v1'), isNull); + expect(await files.readCache(file, 'v2'), [2]); + }); + + test('local subjects come from Valorant config folder names', () async { + final config = p.join(root.path, 'VALORANT', 'Saved', 'Config'); + await Directory( + p.join(config, '5B6BD243-D9A3-549D-B84E-2B7D0428C290-na'), + ).create(recursive: true); + await Directory(p.join(config, 'WindowsClient')).create(); + await Directory(p.join(config, 'Dara-PC-Duo-2A2FF0E0-na')).create(); + expect(await files.localSubjects(), { + '5b6bd243-d9a3-549d-b84e-2b7d0428c290', + }); + }); +} diff --git a/test/replay_map_projection_test.dart b/test/replay_map_projection_test.dart new file mode 100644 index 000000000..88d8bf669 --- /dev/null +++ b/test/replay_map_projection_test.dart @@ -0,0 +1,228 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; + +const _degrees = math.pi / 180; + +void _expectOffset(Offset actual, Offset expected, {double tolerance = 1e-3}) { + expect(actual.dx, closeTo(expected.dx, tolerance), reason: '$actual'); + expect(actual.dy, closeTo(expected.dy, tolerance), reason: '$actual'); +} + +double _wrap(double radians) => (radians + math.pi) % (2 * math.pi) - math.pi; + +void main() { + setUp(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + group('forMapPath', () { + test('knows every Icarus map by its /Game/Maps/ codename', () { + const paths = { + '/Game/Maps/Ascent/Ascent': MapValue.ascent, + '/Game/Maps/Duality/Duality': MapValue.bind, + '/Game/Maps/Triad/Triad': MapValue.haven, + '/Game/Maps/Bonsai/Bonsai': MapValue.split, + '/Game/Maps/Port/Port': MapValue.icebox, + '/Game/Maps/Foxtrot/Foxtrot': MapValue.breeze, + '/Game/Maps/Canyon/Canyon': MapValue.fracture, + '/Game/Maps/Pitt/Pitt': MapValue.pearl, + '/Game/Maps/Jam/Jam': MapValue.lotus, + '/Game/Maps/Juliett/Juliett': MapValue.sunset, + '/Game/Maps/Infinity/Infinity': MapValue.abyss, + '/Game/Maps/Rook/Rook': MapValue.corrode, + '/Game/Maps/Plummet/Plummet': MapValue.summit, + }; + for (final MapEntry(key: path, value: map) in paths.entries) { + expect(ReplayMapProjection.forMapPath(path)?.map, map, reason: path); + } + expect(paths.values.toSet(), MapValue.values.toSet()); + }); + + test('is null for maps Icarus does not have', () { + expect( + ReplayMapProjection.forMapPath('/Game/Maps/Poveglia/Range'), isNull); + expect( + ReplayMapProjection.forMapPath( + '/Game/Maps/HURM/HURM_Alley/HURM_Alley'), + isNull); + expect(ReplayMapProjection.forMapPath(''), isNull); + }); + }); + + // Real positions from 13.00 replays, projected independently in Python from + // the alignment JSON. Each lands on walkable floor of the attack SVG. + test('pins real replay landmarks on the four maps with replays', () { + const pins = [ + // Spike spawns are in the attacker spawn; plants are on B site. + ( + MapValue.sunset, + -5084.60009765625, + 514.9000244140625, + Offset(222.3767, 420.6966) + ), + (MapValue.sunset, 566.5, -4499.39990234375, Offset(48.0184, 224.1953)), + (MapValue.lotus, 1175.0, 800.0, Offset(256.948, 418.7609)), + ( + MapValue.lotus, + 6946.2001953125, + -383.8999938964844, + Offset(209.1716, 185.8635) + ), + (MapValue.split, 1950.0, 400.0, Offset(214.7821, 429.4453)), + (MapValue.split, -2221.800048828125, -6302.5, Offset(51.6806, 167.403)), + (MapValue.summit, 1039.0, 5900.0, Offset(205.0441, 418.4595)), + ( + MapValue.summit, + 8772.2001953125, + 1589.4000244140625, + Offset(58.6078, 155.7533) + ), + ]; + for (final (map, x, y, svg) in pins) { + _expectOffset(ReplayMapProjection.forMap(map).attackSvgPoint(x, y), svg); + } + }); + + test('pins game (1000, 2000) cm on every map', () { + const pins = { + MapValue.ascent: Offset(194.5062, 448.6947), + MapValue.breeze: Offset(273.2865, 365.8105), + MapValue.lotus: Offset(305.3741, 425.8231), + MapValue.icebox: Offset(73.7552, 201.8967), + MapValue.sunset: Offset(274.0169, 209.1216), + MapValue.split: Offset(177.6407, 491.9992), + MapValue.haven: Offset(166.0457, 583.8712), + MapValue.fracture: Offset(351.7027, 530.862), + MapValue.abyss: Offset(264.0622, 311.9456), + MapValue.pearl: Offset(299.1495, 396.2057), + MapValue.bind: Offset(280.4242, 433.0389), + MapValue.corrode: Offset(225.217, 308.8952), + MapValue.summit: Offset(72.5564, 419.7843), + }; + for (final MapEntry(key: map, value: svg) in pins.entries) { + _expectOffset( + ReplayMapProjection.forMap(map).attackSvgPoint(1000, 2000), svg); + } + }); + + group('on every map', () { + for (final map in MapValue.values) { + test('$map: world point is where the app draws the SVG point', () { + final projection = ReplayMapProjection.forMap(map); + final transform = SvgHeightMapTransform.forMap(map); + final coordinates = CoordinateSystem.instance; + for (final (x, y) in const [ + (0.0, 0.0), + (1000.0, 2000.0), + (-3500.0, 4200.0) + ]) { + final world = projection.toWorld(x, y); + _expectOffset( + world, + transform.sideWorldFromSource(projection.attackSvgPoint(x, y), + isAttack: true), + tolerance: 1e-9); + // The defense view of the saved point is the defense SVG point. + final defenseWorld = coordinates.positionForSide( + canonicalPosition: world, + reflectionOffset: Offset.zero, + isAttack: false); + _expectOffset( + transform.sourceFromSideWorld(defenseWorld, isAttack: false), + projection.defenseSvgPoint(x, y), + tolerance: 1e-9); + } + }); + + test('$map: game to map is a turn and a uniform scale, never a mirror', + () { + final projection = ReplayMapProjection.forMap(map); + final origin = projection.toWorld(0, 0); + final alongX = projection.toWorld(100, 0) - origin; + final alongY = projection.toWorld(0, 100) - origin; + expect(alongX.distance, closeTo(alongY.distance, 1e-9)); + expect(alongX.dx * alongY.dx + alongX.dy * alongY.dy, closeTo(0, 1e-9)); + // Seen from above, Unreal's +X to +Y turns clockwise, as on screen. + expect(alongX.dx * alongY.dy - alongX.dy * alongY.dx, greaterThan(0)); + expect(projection.worldLength(100), closeTo(alongX.distance, 1e-9)); + }); + + test('$map: rotation points the way the yaw moves the player', () { + final projection = ReplayMapProjection.forMap(map); + for (var yaw = 0.0; yaw < 360; yaw += 15) { + final step = projection.toWorld(1000 * math.cos(yaw * _degrees), + 1000 * math.sin(yaw * _degrees)) - + projection.toWorld(0, 0); + // A drag toward screen direction (dx, dy) saves atan2 + pi / 2. + final expected = math.atan2(step.dy, step.dx) + math.pi / 2; + expect(_wrap(projection.rotationForYaw(yaw) - expected), + closeTo(0, 1e-9), + reason: 'yaw $yaw'); + } + }); + } + }); + + group('rotationForYaw on real replay movement', () { + // A player running straight out of Split's attacker spawn, replay + // c8989335 at 1188.5 s to 1189.3 s: x stays 1950 while y falls from 472 + // to 42, yaw 270. On screen they run straight up, rotation 0. + test('Split attacker spawn corridor, looking up the screen', () { + final projection = ReplayMapProjection.forMap(MapValue.split); + final start = projection.toWorld(1950, 472.1); + final end = projection.toWorld(1950, 41.79); + expect(end.dx, closeTo(start.dx, 1e-9)); + expect(end.dy, lessThan(start.dy)); + expect(_wrap(projection.rotationForYaw(270)), closeTo(0, 1e-9)); + }); + + // Replay 1fb53a2c at 741.0 s to 742.0 s: running up Summit's attacker + // spawn corridor along +X at yaw 0.09 then 359.94. + test('Summit attacker spawn corridor, looking up the screen', () { + final projection = ReplayMapProjection.forMap(MapValue.summit); + final start = projection.toWorld(764.47, 6200.72); + final end = projection.toWorld(1438.95, 6203.13); + expect(end.dy, lessThan(start.dy)); + expect((end.dx - start.dx).abs(), lessThan(0.1 * (start.dy - end.dy))); + expect(_wrap(projection.rotationForYaw(0.093384)).abs(), lessThan(0.01)); + expect(_wrap(projection.rotationForYaw(359.93958)).abs(), lessThan(0.01)); + }); + + // Replay c8313344 at 16.0 s to 17.3 s: leaving Sunset's attacker spawn + // up and to the left at yaw 320.6, which on screen is 39.4 degrees + // anticlockwise of straight up. + test('Sunset leaving attacker spawn, looking up and left', () { + final projection = ReplayMapProjection.forMap(MapValue.sunset); + final start = projection.toWorld(-4621.59, 719.23); + final end = projection.toWorld(-3968.47, 185.04); + final travelled = end - start; + final rotation = projection.rotationForYaw(320.614); + expect(rotation, closeTo(-39.386 * _degrees, 1e-3)); + expect( + _wrap(math.atan2(travelled.dy, travelled.dx) + math.pi / 2 - rotation) + .abs(), + lessThan(2 * _degrees)); + }); + }); + + test('defense rotation is the canonical rotation turned half way', () { + // Saved rotations are canonical; the defense view adds pi, matching the + // half turn that takes the attack SVG to the defense SVG. + final projection = ReplayMapProjection.forMap(MapValue.split); + final a = projection.defenseSvgPoint(1950, 472.1); + final b = projection.defenseSvgPoint(1950, 41.79); + final travelled = b - a; + final defenseRotation = CoordinateSystem.instance + .rotationForSide(projection.rotationForYaw(270), isAttack: false); + expect( + _wrap(math.atan2(travelled.dy, travelled.dx) + + math.pi / 2 - + defenseRotation) + .abs(), + lessThan(1e-9)); + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index e43851d4b..8ea9de975 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -57,6 +57,8 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) add_subdirectory("runner") add_subdirectory("../native/height" "native/height") add_dependencies(${BINARY_NAME} icarus_height) +add_subdirectory("../native/replay" "native/replay") +add_dependencies(${BINARY_NAME} icarus_replay) # Generated plugin build rules, which manage building the plugins and adding @@ -89,6 +91,8 @@ set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") install(TARGETS icarus_height RUNTIME DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +install(FILES "${ICARUS_REPLAY_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" COMPONENT Runtime) From a2ff1a8bef2fcf7734cbd19e424bf84bd946fdd1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 00:37:20 -0400 Subject: [PATCH 02/18] WIP: harden replay reading, finish ability catalog, glossary Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 16 ++ lib/replay/replay_ability_catalog.dart | 64 ++++---- lib/replay/replay_document.dart | 49 ++---- lib/widgets/replay/replay_dock.dart | 2 +- test/replay_ability_catalog_test.dart | 219 +++++++++++++++++++++---- 5 files changed, 253 insertions(+), 97 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index e951f1111..69d29bf24 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -49,3 +49,19 @@ into lineup groups. Icarus's zip-based strategy interchange format for import/export of whole strategies. Unrelated to video export. _Avoid_: archive (ambiguous with library backups) + +**Replay**: +A Valorant match recording (a `.vrf` file) that Valorant downloads from a +player's career page. Icarus plays it back on the map. Replays are files, not +part of the library; decoding one only fills a rebuildable cache. +_Avoid_: demo, VOD, recording + +**Perspective**: +The team a replay is watched from. Its players are allies, the other team's +are enemies, and its side each round decides which way up the map is drawn. +_Avoid_: point of view, team view + +**Capture**: +Saving the replay's current moment as a page of a strategy: players, their +facing, and the utility on the map at that instant. +_Avoid_: snapshot, export diff --git a/lib/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart index fb2145e3e..6d7eb436d 100644 --- a/lib/replay/replay_ability_catalog.dart +++ b/lib/replay/replay_ability_catalog.dart @@ -171,14 +171,18 @@ class _AtPoint extends _Placement { /// A wall or directed area that starts at the utility and runs one way. /// -/// It starts at the first shape point, else where the utility spawned, and -/// runs toward the last shape point, else along the utility's yaw. Icarus -/// anchors these squares at the caster, a handle's width (plus the -/// ability's gap) short of the shape's near edge, so the anchor is set back -/// by that much and the near edge lands on the start. A resizable square -/// also takes its length from start to end. +/// With two or more shape points it runs from the first to the last. With +/// one, it runs from the utility to that point (a wire's far anchor). With +/// none, it starts at the utility and runs along its yaw plus [yawOffset]. +/// Icarus anchors these squares at the caster, the ability's gap short of +/// the shape's near edge (the squares' widgets lay the area out that way), +/// so the anchor is set back by the gap and the near edge lands on the +/// start. A resizable square also takes its length from start to end. class _FromStart extends _Placement { - const _FromStart(); + const _FromStart({this.yawOffset = 0}); + + /// Degrees from the utility's yaw to the way the shape runs. + final double yawOffset; @override _Geometry resolve({ @@ -188,10 +192,10 @@ class _FromStart extends _Placement { required Ability shape, required double mapScale, }) { - final startPoint = - utility.points.isEmpty ? utility.position : utility.points.first; + final points = utility.points; + final startPoint = points.length >= 2 ? points.first : utility.position; final start = projection.toWorld(startPoint.x, startPoint.y); - final endPoint = utility.points.length >= 2 ? utility.points.last : null; + final endPoint = points.isEmpty ? null : points.last; final end = endPoint == null ? null : projection.toWorld(endPoint.x, endPoint.y); @@ -199,7 +203,7 @@ class _FromStart extends _Placement { if (end != null && end != start) { rotation = _rotationToward(end - start); } else if (utility.yaw != null) { - rotation = projection.rotationForYaw(utility.yaw!); + rotation = projection.rotationForYaw(utility.yaw! + yawOffset); } else { rotation = 0; } @@ -208,10 +212,8 @@ class _FromStart extends _Placement { var nearEdge = 0.0; var length = 0.0; if (shape is SquareAbility) { - final anchor = storedAbilityAnchor(ability: shape, mapScale: mapScale); - nearEdge = coordinates.virtualLengthToWorld( - anchor.dy - shape.height * mapScale, - ); + nearEdge = + coordinates.virtualLengthToWorld(shape.distanceBetweenAOE * mapScale); if (shape is ResizableSquareAbility && end != null) { length = (end - start).distance / coordinates.virtualLengthToWorld(mapScale); @@ -353,10 +355,11 @@ final Map _catalog = { 'GameObject_Smonk_NewSmoke_PDS': _area(AgentType.clove, 2), // Cypher (Gumshoe). The Trapwire actor sits on its first anchor, its yaw - // toward the second; the wire's end is the _SecondWire actor's position, - // which the decoder carries as the wire's shape points. + // toward the second, which is the _SecondWire actor's position. Given + // that as a shape point the wire runs to it; without one it runs along the + // yaw at full length. The thrown cage never replicates where it lands + // (its actor stays on Cypher), so only the cage zone is drawn. 'GameObject_Gumshoe_E_TripWire': _wall(AgentType.cypher, 0), - 'Projectile_Gumshoe_4_CageTrap': _marker(AgentType.cypher, 1), 'Zone_Gumshoe_4_Cage': _area(AgentType.cypher, 1), 'Pawn_Gumshoe_Q_PossessableCamera': _marker(AgentType.cypher, 2), 'GameObject_RemovableObject_GumshoeTrackingDart': @@ -364,12 +367,14 @@ final Map _catalog = { // Deadlock (Cable). Icarus index 0 draws GravNet and index 2 the Barrier // Mesh (by icon and shape), though agents.dart names them the other way. + // Sonic Sensor's yaw faces out of the surface it is stuck to. Annihilation's + // own actor stays on Deadlock; the cocoon is where it caught someone. 'Patch_NetToss': _area(AgentType.deadlock, 0), 'GameObject_StealthingTrap_SoundSensor': _rectangle(AgentType.deadlock, 1), 'GameObject_SoundSensor_SweetSpotFissure': _rectangle(AgentType.deadlock, 1), 'GameObject_CableJamRoot': const ReplayAbilityEntry._( AgentType.deadlock, 2, ReplayUtilityKind.barrierMesh, _BarrierMesh()), - 'Actor_FishingHook': _projectile(AgentType.deadlock, 3), + 'GameObject_FishingHook_CageSphere': _marker(AgentType.deadlock, 3), // Fade (BountyHunter). 'Pawn_BountyHunter_4_WolfHound': _marker(AgentType.fade, 0), @@ -386,10 +391,10 @@ final Map _catalog = { 'Projectile_E_Aggrobot_DiscTurret_PowerWave': _projectile(AgentType.gekko, 2), 'Pawn_Aggrobot_RollyPolly': _marker(AgentType.gekko, 3), - // Harbor (Mage), asset index. + // Harbor (Mage), asset index. Icarus draws High Tide as its icon, so it + // follows the wall's guided head (Giehl's MageWallDescriptor). 'GameObject_Mage_4_SplashGrenade': _area(AgentType.harbor, 0), - 'Projectile_Mage_Q_Wall': const ReplayAbilityEntry._( - AgentType.harbor, 1, ReplayUtilityKind.wall, _point), + 'Projectile_Mage_Q_Wall': _projectile(AgentType.harbor, 1), 'GameObject_Mage_E_WorldSmoke': _area(AgentType.harbor, 2), 'GameObject_Mage_X_TidalWave': _rectangle(AgentType.harbor, 3), @@ -439,12 +444,11 @@ final Map _catalog = { 'Intention_Wraith_X_GlobalTeleport': _marker(AgentType.omen, 3), // Phoenix. Icarus index 1 is Curveball and index 2 Hot Hands (by icon and - // shape), though agents.dart names them the other way. + // shape), though agents.dart names them the other way. Curveball's actors + // stay on Phoenix and never replicate the flare's flight, so it is not + // drawn. 'GameObject_Phoenix_Q_FlameWallManager_Production': _wall(AgentType.pheonix, 0), - 'Projectile_Phoenix_E_FlareCurve_Synced': _projectile(AgentType.pheonix, 1), - 'Projectile_Phoenix_E_FlareCurve_Synced_Right': - _projectile(AgentType.pheonix, 1), 'Patch_Phoenix_MolotovFire': _area(AgentType.pheonix, 2), 'GameObject_Phoenix_X_ResTarget_Production': _marker(AgentType.pheonix, 3), @@ -503,9 +507,11 @@ final Map _catalog = { 'Patch_Pandemic_X_Circular': _area(AgentType.viper, 3), // Vyse (Nox). Icarus index 0 draws Shear and index 1 Razorvine (by icon - // and shape), though agents.dart names them the other way. Shear's wall - // and its armed trap both replicate a start and an end. - 'GameObject_Nox_Wall': _wall(AgentType.vyse, 0), + // and shape), though agents.dart names them the other way. Shear's armed + // trap and its raised wall both sit on the wall's start; the trap's yaw + // runs along the wall and the raised wall's faces across it. + 'GameObject_Nox_Wall': const ReplayAbilityEntry._( + AgentType.vyse, 0, ReplayUtilityKind.wall, _FromStart(yawOffset: -90)), 'GameObject_Nox_WallTrap': _wall(AgentType.vyse, 0), 'GameObject_Nox_BarbedWire': _area(AgentType.vyse, 1), 'Patch_Nox_BarbedWire': _area(AgentType.vyse, 1), diff --git a/lib/replay/replay_document.dart b/lib/replay/replay_document.dart index af08012dc..4606b69cf 100644 --- a/lib/replay/replay_document.dart +++ b/lib/replay/replay_document.dart @@ -4,7 +4,8 @@ import 'dart:typed_data'; /// One decoded Valorant replay, read from the `.icrp` buffer that /// `native/replay` writes. Facts only, in game units: no Icarus types appear -/// here. The format is specified in `docs/replay-format.md`. +/// here. The format is specified in `docs/replay-format.md`; ability casts +/// are in it too but nothing reads them yet, so they are not parsed. class ReplayDocument { ReplayDocument._({ required this.match, @@ -12,7 +13,6 @@ class ReplayDocument { required this.rounds, required this.kills, required this.utility, - required this.casts, required this.vitals, required this.movement, required this.quality, @@ -26,7 +26,6 @@ class ReplayDocument { final List rounds; final List kills; final List utility; - final List casts; /// Health and armor per player, one row per change. final Map vitals; @@ -64,7 +63,13 @@ class ReplayDocument { final blob = blobStart <= bytes.length ? ByteData.sublistView(bytes, blobStart) : ByteData(0); - return ReplayDocument._fromJson(json, blob); + try { + return ReplayDocument._fromJson(json, blob); + } on TypeError catch (error) { + // A field of the wrong type or missing: the buffer is not what this + // reader understands, which callers handle like any bad format. + throw FormatException('Decoded replay is malformed: $error'); + } } factory ReplayDocument._fromJson(Map json, ByteData blob) { @@ -84,8 +89,6 @@ class ReplayDocument { ..sort((a, b) => a.timeMs.compareTo(b.timeMs)), utility: [for (final u in list('utility')) ReplayUtility.fromJson(u)] ..sort((a, b) => a.spawnMs.compareTo(b.spawnMs)), - casts: [for (final c in list('casts')) ReplayCast.fromJson(c)] - ..sort((a, b) => a.timeMs.compareTo(b.timeMs)), vitals: { for (final entry in vitalsJson.entries) entry.key: ReplayVitalsTrack.fromJson(entry.value as List), @@ -297,14 +300,14 @@ class ReplayEconomy { const ReplayEconomy({ required this.subject, required this.credits, - required this.loadoutValue, + this.loadoutValue, this.weapon, this.armor, }); final String subject; final int credits; - final int loadoutValue; + final int? loadoutValue; /// Equippable class path of the best weapon carried into the round. final String? weapon; @@ -315,7 +318,7 @@ class ReplayEconomy { factory ReplayEconomy.fromJson(Map json) => ReplayEconomy( subject: json['subject'] as String, credits: json['credits'] as int, - loadoutValue: json['loadoutValue'] as int, + loadoutValue: json['loadoutValue'] as int?, weapon: json['weapon'] as String?, armor: json['armor'] as int?, ); @@ -407,29 +410,6 @@ class ReplayUtility { ); } -class ReplayCast { - const ReplayCast({ - required this.timeMs, - required this.subject, - required this.classPath, - this.position, - }); - - final int timeMs; - final String subject; - final String classPath; - final ReplayVector? position; - - factory ReplayCast.fromJson(Map json) => ReplayCast( - timeMs: json['timeMs'] as int, - subject: json['subject'] as String, - classPath: json['classPath'] as String, - position: json['position'] == null - ? null - : ReplayVector.fromJson(json['position'] as List), - ); -} - class ReplayQuality { const ReplayQuality({ required this.transformVerified, @@ -469,8 +449,9 @@ class ReplayPathPoint { final int timeMs; final ReplayVector position; + // Rows are number arrays, so the time may arrive written as 1200.0. factory ReplayPathPoint.fromJson(List json) => ReplayPathPoint( - json[0] as int, + (json[0] as num).toInt(), ReplayVector( (json[1] as num).toDouble(), (json[2] as num).toDouble(), @@ -603,7 +584,7 @@ class ReplayVitalsTrack { final armor = Float32List(rows.length); for (var i = 0; i < rows.length; i++) { final row = rows[i] as List; - times[i] = row[0] as int; + times[i] = (row[0] as num).toInt(); health[i] = (row[1] as num).toDouble(); armor[i] = (row[2] as num).toDouble(); } diff --git a/lib/widgets/replay/replay_dock.dart b/lib/widgets/replay/replay_dock.dart index 0fa25d838..9ba0e680f 100644 --- a/lib/widgets/replay/replay_dock.dart +++ b/lib/widgets/replay/replay_dock.dart @@ -317,7 +317,7 @@ class _SpeedButton extends StatelessWidget { @override Widget build(BuildContext context) { - final speeds = ReplayPlayback.speeds; + const speeds = ReplayPlayback.speeds; final next = speeds[(speeds.indexOf(playback.speed) + 1) % speeds.length]; final label = playback.speed == playback.speed.roundToDouble() ? '${playback.speed.round()}×' diff --git a/test/replay_ability_catalog_test.dart b/test/replay_ability_catalog_test.dart index 5c4f869ed..f5e2ed62e 100644 --- a/test/replay_ability_catalog_test.dart +++ b/test/replay_ability_catalog_test.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; -import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/agents.dart'; @@ -11,6 +12,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/replay/replay_ability_catalog.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart'; void main() { @@ -67,18 +69,56 @@ void main() { Offset unitFor(double rotation) => Offset(math.sin(rotation), -math.cos(rotation)); - /// Screen distance from a square's anchor to the near edge of its area. - double anchorToNearEdge(SquareAbility shape, double mapScale) => - coordinates.scale( - storedAbilityAnchor(ability: shape, mapScale: mapScale).dy - - shape.height * mapScale, - ); + /// Where a square's area starts and how long it runs, measured off the + /// widget the editor draws for [ability], unrotated: [gap] from the anchor + /// (the icon's centre) to the area's near edge, and the area's [length], + /// both in screen pixels. + Future<({double gap, double length})> measureSquare( + WidgetTester tester, + PlacedAbility ability, + ReplayMapProjection projection, + ) async { + tester.view.physicalSize = const Size(3000, 3000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Center( + child: ability.data.abilityData!.createWidget( + id: ability.id, + isAlly: true, + mapScale: mapScaleOf(projection), + length: ability.length, + watchMouse: false, + ), + ), + ), + ), + ); + final area = + tester.getRect(find.byKey(const ValueKey('square-range-body'))); + final icon = tester.getCenter(find.byType(AbilityWidget)); + return (gap: icon.dy - area.bottom, length: area.height); + } Matcher near(Offset expected, {double pixels = 1}) => predicate( (actual) => (actual - expected).distance <= pixels, 'within $pixels px of $expected', ); + /// Whether a rotation points the same way as a screen direction. + Matcher pointsAlong(Offset direction, {double degrees = 1}) => + predicate( + (rotation) { + final unit = direction / direction.distance; + final along = unitFor(rotation); + final cosine = unit.dx * along.dx + unit.dy * along.dy; + return cosine >= math.cos(degrees * math.pi / 180); + }, + 'within $degrees degrees of $direction', + ); + group('class paths seen in the 13.00 replays', () { test('map onto the Icarus ability they draw', () { for (final MapEntry(key: path, value: (agent, index)) @@ -199,7 +239,8 @@ void main() { wall.rotation, closeTo(projection.rotationForYaw(347.68 + 90), 1e-9)); }); - test("Neon's Fast Lane runs from its first tunnel point to its last", () { + testWidgets("Neon's Fast Lane runs from its first tunnel point to its last", + (tester) async { // c8989335 (Split), the tunnel at 57646 ms. final projection = ReplayMapProjection.forMap(MapValue.split); const start = ReplayVector(6081, -4858, 210); @@ -214,23 +255,22 @@ void main() { ), projection, ); - final shape = lane.data.abilityData! as ResizableSquareAbility; - final mapScale = mapScaleOf(projection); + final drawn = await measureSquare(tester, lane, projection); final anchor = drawnAnchor(lane, projection); final direction = unitFor(lane.rotation); - final nearEdge = anchorToNearEdge(shape, mapScale); - final drawnLength = - coordinates.scale(shape.resolveLength(lane.length) * mapScale); - expect(anchor + direction * nearEdge, near(screenOf(projection, start))); - expect(anchor + direction * (nearEdge + drawnLength), + expect(anchor + direction * drawn.gap, near(screenOf(projection, start))); + expect(anchor + direction * (drawn.gap + drawn.length), near(screenOf(projection, end))); }); - test("Breach's Fault Line starts at the fissure and runs along its yaw", () { - // dc078274 (Sunset): the fissure spawns 8 m ahead of Breach, on yaw. + testWidgets( + "Breach's Fault Line starts at the fissure and runs along its yaw", + (tester) async { + // dc078274 (Sunset) at 66945 ms: the fissure spawns 8 m ahead of + // Breach's CharacterLocation (955, -612), on its yaw. final projection = ReplayMapProjection.forMap(MapValue.sunset); - const at = ReplayVector(955, -1412, 300); + const at = ReplayVector(960, -1412, 300); final fissure = place( utility( '/Game/Characters/Breach/S0/Ability_E/GameObject_Breach_E_SweetSpot' @@ -240,15 +280,133 @@ void main() { ), projection, ); - final shape = fissure.data.abilityData! as SquareAbility; - final nearEdge = anchorToNearEdge(shape, mapScaleOf(projection)); + final drawn = await measureSquare(tester, fissure, projection); - expect(fissure.rotation, closeTo(projection.rotationForYaw(270.3), 1e-9)); expect( - drawnAnchor(fissure, projection) + unitFor(fissure.rotation) * nearEdge, + fissure.rotation, + pointsAlong(screenOf(projection, at) - + screenOf(projection, const ReplayVector(955, -612, 300)))); + expect( + drawnAnchor(fissure, projection) + + unitFor(fissure.rotation) * drawn.gap, near(screenOf(projection, at))); }); + testWidgets("Cypher's Trapwire runs from its anchor to the second wire", + (tester) async { + // dc078274 (Sunset) at 208146 ms: the TripWire actor and its + // _SecondWire, given to the wire as its one shape point. + final projection = ReplayMapProjection.forMap(MapValue.sunset); + const first = ReplayVector(677, 2203, 261); + const second = ReplayVector(681, 2735, 261); + final wire = place( + utility( + '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire.' + 'GameObject_Gumshoe_E_TripWire_C', + position: first, + yaw: 90, + points: const [second], + ), + projection, + ); + final drawn = await measureSquare(tester, wire, projection); + final anchor = drawnAnchor(wire, projection); + final direction = unitFor(wire.rotation); + + expect(anchor + direction * drawn.gap, near(screenOf(projection, first))); + expect(anchor + direction * (drawn.gap + drawn.length), + near(screenOf(projection, second))); + }); + + test("Cypher's Trapwire without its second wire runs along its yaw", () { + // dc078274 (Sunset) at 246510 ms; the _SecondWire is at + // (-2244, -2043), where the yaw points. + final projection = ReplayMapProjection.forMap(MapValue.sunset); + const first = ReplayVector(-3541, -2047, 532); + final wire = place( + utility( + '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire.' + 'GameObject_Gumshoe_E_TripWire_C', + position: first, + yaw: 360, + ), + projection, + ); + + expect(drawnAnchor(wire, projection), near(screenOf(projection, first))); + expect( + wire.rotation, + pointsAlong( + screenOf(projection, const ReplayVector(-2244, -2043, 532)) - + screenOf(projection, first))); + }); + + test("Vyse's Shear runs from its start toward its end", () { + // d3c0e7a2 (Lotus): the armed trap at 19160 ms and a raised wall at + // 1469036 ms, with their MulticastInitialize* start and end points. + final projection = ReplayMapProjection.forMap(MapValue.lotus); + final cases = [ + ( + asset: 'GameObject_Nox_WallTrap', + at: const ReplayVector(5730, -895, 371), + yaw: 90.0, + start: const ReplayVector(5740, -900, 203), + end: const ReplayVector(5740, 300, 203), + ), + ( + asset: 'GameObject_Nox_Wall', + at: const ReplayVector(1701, -460, 250), + yaw: 270.0, + start: const ReplayVector(1701, -460, 250), + end: const ReplayVector(1005, -460, 250), + ), + ]; + for (final c in cases) { + final shear = place( + utility( + '/Game/Characters/Nox/S0/Ability_Q/${c.asset}.${c.asset}_C', + position: c.at, + yaw: c.yaw, + ), + projection, + ); + + expect((shear.data.type, shear.data.index), (AgentType.vyse, 0), + reason: c.asset); + expect(drawnAnchor(shear, projection), + near(screenOf(projection, c.start), pixels: 2), + reason: c.asset); + expect( + shear.rotation, + pointsAlong( + screenOf(projection, c.end) - screenOf(projection, c.start)), + reason: c.asset); + } + }); + + test("Phoenix's Blaze runs along its replicated wall points", () { + // dc078274 (Sunset) at 281150 ms: the manager stands on Phoenix and + // MulticastAddSmokeScreenPoint lays the wall out ahead of it. + final projection = ReplayMapProjection.forMap(MapValue.sunset); + const first = ReplayVector(-1305, -4718, 200); + const last = ReplayVector(568, -4804, 200); + final blaze = place( + utility( + '/Game/Characters/Phoenix/S0/Ability_Q/Production/GameObject_Phoenix_Q_' + 'FlameWallManager_Production.GameObject_Phoenix_Q_FlameWallManager_' + 'Production_C', + position: const ReplayVector(-1449, -4714, 376), + yaw: 358.2, + points: const [first, ReplayVector(-1180, -4722, 200), last], + ), + projection, + ); + + expect(drawnAnchor(blaze, projection), near(screenOf(projection, first))); + expect(blaze.rotation, + pointsAlong(screenOf(projection, last) - screenOf(projection, first))); + }); + test("Deadlock's Barrier Mesh arms reach each deployer's destination", () { // 1fb53a2c (Summit) at 64113 ms: the root and its four // CableDeployer MulticastInitialize.Destination values. @@ -336,10 +494,8 @@ const observedMapped = { (AgentType.deadlock, 1), '/Game/Characters/Cable/S0/Ability_Q/GameObject_StealthingTrap_SoundSensor.GameObject_StealthingTrap_SoundSensor_C': (AgentType.deadlock, 1), - '/Game/Characters/Cable/S0/Ability_X/Actor_FishingHook.Actor_FishingHook_C': ( - AgentType.deadlock, - 3 - ), + '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_CageSphere.GameObject_FishingHook_CageSphere_C': + (AgentType.deadlock, 3), '/Game/Characters/Cashew/S0/Ability_4/GameObject_Cashew_4_SonarPing.GameObject_Cashew_4_SonarPing_C': (AgentType.tejo, 0), '/Game/Characters/Cashew/S0/Ability_4/Pawn_Cashew_4_Spider_LockOn.Pawn_Cashew_4_Spider_LockOn_C': @@ -388,8 +544,6 @@ const observedMapped = { AgentType.skye, 3 ), - '/Game/Characters/Gumshoe/S0/Ability_4/Projectile_Gumshoe_4_CageTrap.Projectile_Gumshoe_4_CageTrap_C': - (AgentType.cypher, 1), '/Game/Characters/Gumshoe/S0/Ability_4/Zone_Gumshoe_4_Cage.Zone_Gumshoe_4_Cage_C': (AgentType.cypher, 1), '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire.GameObject_Gumshoe_E_TripWire_C': @@ -438,10 +592,6 @@ const observedMapped = { (AgentType.vyse, 3), '/Game/Characters/Phoenix/S0/Ability_4/Production/NewMolotov/Patch_Phoenix_MolotovFire.Patch_Phoenix_MolotovFire_C': (AgentType.pheonix, 2), - '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced.Projectile_Phoenix_E_FlareCurve_Synced_C': - (AgentType.pheonix, 1), - '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced_Right.Projectile_Phoenix_E_FlareCurve_Synced_Right_C': - (AgentType.pheonix, 1), '/Game/Characters/Phoenix/S0/Ability_Q/Production/GameObject_Phoenix_Q_FlameWallManager_Production.GameObject_Phoenix_Q_FlameWallManager_Production_C': (AgentType.pheonix, 0), '/Game/Characters/Phoenix/S0/Ability_X/Production/GameObject_Phoenix_X_ResTarget_Production.GameObject_Phoenix_X_ResTarget_Production_C': @@ -529,8 +679,8 @@ const observedIgnored = [ '/Game/Characters/Cable/S0/Ability_E/Projectile_CableJam_InAir.Projectile_CableJam_InAir_C', '/Game/Characters/Cable/S0/Ability_Q/Ability_Cable_Q_SoundSensor.Ability_Cable_Q_SoundSensor_C', '/Game/Characters/Cable/S0/Ability_X/Ability_Cable_X_FishingHook.Ability_Cable_X_FishingHook_C', + '/Game/Characters/Cable/S0/Ability_X/Actor_FishingHook.Actor_FishingHook_C', '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_BouncingTrajectoryWarning.GameObject_FishingHook_BouncingTrajectoryWarning_C', - '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_CageSphere.GameObject_FishingHook_CageSphere_C', '/Game/Characters/Cable/S0/Ability_X/GameObject_FishingHook_EndOfTrajectoryWarning.GameObject_FishingHook_EndOfTrajectoryWarning_C', '/Game/Characters/Cable/S0/Ability_X/GameObject_MotherNode.GameObject_MotherNode_C', '/Game/Characters/Cable/S0/Ability_X/GameObject_Spline.GameObject_Spline_C', @@ -569,6 +719,7 @@ const observedIgnored = [ '/Game/Characters/Guide/S0/Ability_X/Equippable_Guide_X_Pack_Attack.Equippable_Guide_X_Pack_Attack_C', '/Game/Characters/Guide/S0/Ability_X/Equippable_PlaceholderDefault.Equippable_PlaceholderDefault_C', '/Game/Characters/Gumshoe/S0/Ability_4/Ability_Gumshoe_4_CageTrap.Ability_Gumshoe_4_CageTrap_C', + '/Game/Characters/Gumshoe/S0/Ability_4/Projectile_Gumshoe_4_CageTrap.Projectile_Gumshoe_4_CageTrap_C', '/Game/Characters/Gumshoe/S0/Ability_E/Ability_Gumshoe_E_TripWire.Ability_Gumshoe_E_TripWire_C', '/Game/Characters/Gumshoe/S0/Ability_E/GameObject_Gumshoe_E_TripWire_SecondWire.GameObject_Gumshoe_E_TripWire_SecondWire_C', '/Game/Characters/Gumshoe/S0/Ability_Q/Ability_Gumshoe_Q_Camera.Ability_Gumshoe_Q_Camera_C', @@ -604,6 +755,8 @@ const observedIgnored = [ '/Game/Characters/Phoenix/S0/Ability_4/Production/Ability_Phoenix_4_Molotov_Production.Ability_Phoenix_4_Molotov_Production_C', '/Game/Characters/Phoenix/S0/Ability_4/Production/Projectile_Phoenix_4_Molotov_Production.Projectile_Phoenix_4_Molotov_Production_C', '/Game/Characters/Phoenix/S0/Ability_E/Production/Ability_Phoenix_E_FlareCurve_Production.Ability_Phoenix_E_FlareCurve_Production_C', + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced.Projectile_Phoenix_E_FlareCurve_Synced_C', + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced_Right.Projectile_Phoenix_E_FlareCurve_Synced_Right_C', '/Game/Characters/Phoenix/S0/Ability_Q/Production/Ability_Phoenix_Q_FireballWall_Production.Ability_Phoenix_Q_FireballWall_Production_C', '/Game/Characters/Phoenix/S0/Ability_Q/Production/Projectile_Phoenix_Q_FlameWall_ThroughWall.Projectile_Phoenix_Q_FlameWall_ThroughWall_C', '/Game/Characters/Phoenix/S0/Ability_X/Production/Ability_Phoenix_X_SelfRes_Production.Ability_Phoenix_X_SelfRes_Production_C', From 2c67be737860075f753e40f47d90b70093efd692 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 00:38:01 -0400 Subject: [PATCH 03/18] Add clean-room Selkie decoder for replay archives Written from a facts-only format spec; decodes all 259 archives in the local replay corpus byte for byte at about 1 GB/s. Co-Authored-By: Claude Opus 5.5 (1M context) --- native/replay/src/lib.rs | 1 + native/replay/src/oodle.rs | 23 +- native/replay/src/selkie.rs | 1188 +++++++++++++++++++++++++ native/replay/tests/selkie_vectors.rs | 85 ++ 4 files changed, 1285 insertions(+), 12 deletions(-) create mode 100644 native/replay/src/selkie.rs create mode 100644 native/replay/tests/selkie_vectors.rs diff --git a/native/replay/src/lib.rs b/native/replay/src/lib.rs index b662b2e5f..6b7c5e7d6 100644 --- a/native/replay/src/lib.rs +++ b/native/replay/src/lib.rs @@ -12,6 +12,7 @@ mod fieldpath; pub mod guard; pub mod header; pub mod oodle; +pub mod selkie; mod vendor; use std::path::Path; diff --git a/native/replay/src/oodle.rs b/native/replay/src/oodle.rs index ff55b2232..519f54913 100644 --- a/native/replay/src/oodle.rs +++ b/native/replay/src/oodle.rs @@ -1,15 +1,16 @@ //! The Oodle seam. Every compressed archive in a replay goes through one //! [`Decompressor`]; nothing else in the crate knows how Oodle works. //! -//! The production decoder is our own clean-room Selkie decoder, which fills -//! this seam when it lands. Until then a build has either no decoder -//! ([`Unavailable`], which reports `unsupportedCompression`) or, with the -//! `dev-vectors` feature, [`VectorDecompressor`], which looks each archive up -//! in a directory of recorded input/output pairs. +//! The production decoder is our own clean-room [`Selkie`]. With the +//! `dev-vectors` feature and `ICARUS_OODLE_VECTORS` set, a build instead uses +//! [`VectorDecompressor`], which looks each archive up in a directory of +//! recorded input/output pairs. [`Unavailable`] refuses every archive, for +//! exercising the `unsupportedCompression` path. use vrf_container::parse_replay_data_meta; use crate::error::{ErrorCode, ReplayError}; +use crate::selkie::Selkie; /// Inflates one Oodle archive body (the bytes after its 8-byte header) to /// exactly `decompressed_size` bytes. @@ -17,22 +18,20 @@ pub trait Decompressor: Send + Sync { fn decompress(&self, input: &[u8], decompressed_size: usize) -> Result, ReplayError>; } -/// No decoder in this build: every compressed archive is refused. +/// No decoder: every compressed archive is refused. pub struct Unavailable; impl Decompressor for Unavailable { fn decompress(&self, _input: &[u8], decompressed_size: usize) -> Result, ReplayError> { Err(ReplayError::new( ErrorCode::UnsupportedCompression, - format!( - "this build has no Oodle decoder (a {decompressed_size}-byte archive needs one)" - ), + format!("no Oodle decoder (a {decompressed_size}-byte archive needs one)"), )) } } -/// The decompressor this build ships with: the dev vectors when the feature -/// is on and `ICARUS_OODLE_VECTORS` names a directory, otherwise none. +/// The decompressor this build ships with: [`Selkie`], or the dev vectors +/// when the feature is on and `ICARUS_OODLE_VECTORS` names a directory. pub fn default_decompressor() -> Box { #[cfg(feature = "dev-vectors")] if let Some(dir) = std::env::var_os("ICARUS_OODLE_VECTORS") { @@ -41,7 +40,7 @@ pub fn default_decompressor() -> Box { Err(error) => Box::new(Broken(error)), }; } - Box::new(Unavailable) + Box::new(Selkie) } /// A vectors directory that could not be indexed: every archive reports why. diff --git a/native/replay/src/selkie.rs b/native/replay/src/selkie.rs new file mode 100644 index 000000000..df0013af4 --- /dev/null +++ b/native/replay/src/selkie.rs @@ -0,0 +1,1188 @@ +//! Selkie: our Oodle decoder for the subset Valorant replays use (decoder +//! type 10, mode-1 LZ chunks with raw substreams, raw chunks, raw blocks). +//! +//! Written clean-room. Its only sources were the facts-only format notes in +//! `icarus-replay-work/oodle/`: `spec.md`, `corpus-variants.md`, +//! `examples.json` and `census.json`, plus the recorded archive pairs in +//! `vectors/` (`manifest.json`, `quick.json`, `*.in`/`*.out`). No existing +//! Oodle, Kraken, Mermaid or Selkie decoder source was read. Section names +//! below ("Block header", "Token meanings", ...) are the spec's. +//! +//! Anything outside the profile the corpus uses is refused rather than +//! guessed at: valid-but-unsupported forms report `unsupportedCompression`, +//! malformed bytes report `corrupt`, each naming the archive input position. + +use std::fmt::Display; + +use crate::error::{ErrorCode, ReplayError}; +use crate::oodle::Decompressor; + +/// Output span of a block; blocks start at its multiples. +const BLOCK: usize = 0x40000; +/// Most output one chunk carries. +const CHUNK: usize = 0x20000; +/// Output span of a token region; a chunk has one or two. +const REGION: usize = 0x10000; +/// Bytes copied verbatim at the start of the archive's first LZ chunk. +const INITIAL_BYTES: usize = 8; +/// Shortest match distance the profile allows; also each chunk's starting +/// remembered distance. +const MIN_DISTANCE: usize = 8; +/// Region starts from here on may use a far encoding the profile lacks. +const FAR_REGION_LIMIT: usize = 0xbf_ffff; +/// The quantum-size value that marks a special quantum, not a payload size. +const SPECIAL_QUANTUM: usize = 0x3_ffff; +/// Counts that announce a different encoding rather than a count. +const SPLIT_NEAR_COUNT: usize = 0xffff; +const EXTENDED_FAR_COUNT: usize = 0xfff; +/// Up-front output allocation per input byte. Real archives inflate about +/// 3x; a hostile size beyond this grows only as fast as blocks decode. +const PREALLOC_PER_INPUT_BYTE: usize = 16; +/// Scratch bytes past the current block's end, room for the widest copy to +/// overrun its logical end (see [`Output`]). +const SLACK: usize = 16; + +/// The production [`Decompressor`]. +pub struct Selkie; + +impl Decompressor for Selkie { + fn decompress(&self, input: &[u8], decompressed_size: usize) -> Result, ReplayError> { + decode(input, decompressed_size) + } +} + +/// Inflate one archive body to exactly `size` bytes ("End conditions"). +fn decode(input: &[u8], size: usize) -> Result, ReplayError> { + if size == 0 { + return Err(unsupported(0, "an empty archive")); + } + let mut archive = Stream::new(input, 0, "archive"); + let mut out = Output { + buf: Vec::with_capacity( + size.min(input.len().saturating_mul(PREALLOC_PER_INPUT_BYTE)) + SLACK, + ), + len: 0, + }; + while out.len < size { + let end = (out.len + BLOCK).min(size); + out.buf.resize(end + SLACK, 0); + if block_is_raw(&mut archive, out.len == 0)? { + out.push(archive.take(end - out.len)?); + } else { + let quantum = quantum(&mut archive, end - out.len)?; + decode_quantum(quantum, &mut out, end)?; + } + } + archive.finish()?; + out.buf.truncate(out.len); + Ok(out.buf) +} + +/// The archive's output: `buf[..len]` is decoded. The rest of `buf`, through +/// the current block's end plus [`SLACK`], is scratch. Short copies move +/// whole 8-byte words and may write scratch past their logical end; the +/// cursor rewrites every such byte before anything reads or returns it. +struct Output { + buf: Vec, + len: usize, +} + +impl Output { + /// Append `bytes`, which the caller has checked fit the current block. + fn push(&mut self, bytes: &[u8]) { + self.buf[self.len..self.len + bytes.len()].copy_from_slice(bytes); + self.len += bytes.len(); + } +} + +/// Read a two-byte block header; true for a raw block ("Block header"). +fn block_is_raw(archive: &mut Stream<'_>, first: bool) -> Result { + let at = archive.at; + let &[flags, decoder] = archive.array::<2>()?; + if flags & 0x0f != 0x0c || flags & 0x30 != 0 { + return Err(corrupt( + at, + format_args!("invalid block header {flags:02x} {decoder:02x}"), + )); + } + if decoder & 0x7f != 10 { + return Err(unsupported( + at, + format_args!("decoder type {}", decoder & 0x7f), + )); + } + if decoder & 0x80 != 0 { + return Err(unsupported(at, "a block checksum")); + } + let (restart, raw) = (flags & 0x80 != 0, flags & 0x40 != 0); + match (first, restart, raw) { + (true, true, false) => Ok(false), + (false, false, raw) => Ok(raw), + _ => Err(unsupported( + at, + format_args!("block header {flags:02x} 0a at this position"), + )), + } +} + +/// Read a quantum header and return its payload ("Quantum header"). +fn quantum<'a>(archive: &mut Stream<'a>, block_size: usize) -> Result, ReplayError> { + let at = archive.at; + let header = archive.be24()?; + if header >> 18 != 0 { + return Err(unsupported(at, format_args!("quantum header {header:06x}"))); + } + if header == SPECIAL_QUANTUM { + return Err(unsupported(at, "a special quantum")); + } + let len = header + 1; + if len == block_size { + return Err(unsupported(at, "a raw quantum")); + } + if len > block_size { + return Err(corrupt( + at, + format_args!("a {len}-byte quantum for {block_size} bytes of output"), + )); + } + archive.sub(len, "quantum payload") +} + +/// Decode a quantum's chunks into `out` up to `end`, consuming it exactly +/// ("Chunk header and raw chunks"). +fn decode_quantum( + mut quantum: Stream<'_>, + out: &mut Output, + end: usize, +) -> Result<(), ReplayError> { + while out.len < end { + let chunk_end = (out.len + CHUNK).min(end); + let size = chunk_end - out.len; + let at = quantum.at; + let header = quantum.be24()?; + if header & 0x80_0000 == 0 { + return Err(unsupported(at, "a standalone byte-array chunk")); + } + let (mode, len) = ((header >> 19) & 0xf, header & 0x7_ffff); + if len > size { + return Err(corrupt( + at, + format_args!("a {len}-byte chunk for {size} bytes of output"), + )); + } + match (mode, len == size) { + (1, false) => lz_chunk(quantum.sub(len, "chunk payload")?, out, chunk_end)?, + (0, true) => out.push(quantum.take(len)?), + (0, false) => return Err(unsupported(at, "a mode-0 LZ chunk")), + _ => { + return Err(unsupported( + at, + format_args!("chunk mode {mode} with {len} of {size} bytes"), + )); + } + } + } + quantum.finish() +} + +/// Decode one mode-1 LZ chunk into `out` up to `end` ("Mode-1 LZ payload +/// layout"). +fn lz_chunk(mut payload: Stream<'_>, out: &mut Output, end: usize) -> Result<(), ReplayError> { + let start = out.len; + let size = end - start; + if start == 0 { + if size < INITIAL_BYTES { + return Err(corrupt( + payload.at, + format_args!("a {size}-byte first chunk"), + )); + } + out.push(payload.take(INITIAL_BYTES)?); + } + let literals = byte_array(&mut payload, "literal", size)?; + let commands = byte_array(&mut payload, "command", size)?; + let two_regions = size > REGION; + let split_at = payload.at; + let split = if two_regions { + payload.le16()? + } else { + commands.bytes.len() + }; + if split > commands.bytes.len() { + return Err(corrupt( + split_at, + format_args!("{split} first-region commands of {}", commands.bytes.len()), + )); + } + let near_at = payload.at; + let near_count = payload.le16()?; + if near_count == SPLIT_NEAR_COUNT { + return Err(unsupported(near_at, "a split near-offset stream")); + } + let near = payload.sub(2 * near_count, "near-entry")?; + let far_at = payload.at; + let far_counts = payload.le24()?; + let (first_far, second_far) = (far_counts >> 12, far_counts & 0xfff); + if first_far == EXTENDED_FAR_COUNT || second_far == EXTENDED_FAR_COUNT { + return Err(unsupported(far_at, "an extended far count")); + } + if !two_regions && second_far != 0 { + return Err(corrupt( + far_at, + "second-region far entries in a one-region chunk", + )); + } + let last_region = if two_regions { start + REGION } else { start }; + if last_region >= FAR_REGION_LIMIT { + return Err(unsupported( + far_at, + format_args!("a token region at output {last_region}"), + )); + } + let first_far = payload.sub(3 * first_far, "first-region far-entry")?; + let second_far = payload.sub(3 * second_far, "second-region far-entry")?; + let lengths = payload.rest("length"); + + let (first_commands, second_commands) = commands.split(split); + let mut lz = Lz { + out, + literals, + near, + lengths, + distance: MIN_DISTANCE, + }; + lz.region(first_commands, first_far, start, end.min(start + REGION))?; + if two_regions { + lz.region(second_commands, second_far, start + REGION, end)?; + } + lz.literals.finish()?; + lz.near.finish()?; + lz.lengths.finish() +} + +/// Read a raw byte-array header and its bytes ("Raw byte-array headers"). +fn byte_array<'a>( + payload: &mut Stream<'a>, + name: &'static str, + chunk_size: usize, +) -> Result, ReplayError> { + let at = payload.at; + let header = payload.be24()?; + if header & 0x80_0000 != 0 { + return Err(unsupported( + at, + format_args!("a short {name} byte-array header"), + )); + } + if header & 0x70_0000 != 0 { + return Err(unsupported( + at, + format_args!("{name} byte-array coding type {}", (header >> 20) & 7), + )); + } + if header > chunk_size { + return Err(corrupt( + at, + format_args!("{header} {name} bytes in a {chunk_size}-byte chunk"), + )); + } + payload.sub(header, name) +} + +/// The shared streams of one LZ chunk, consumed token by token. +struct Lz<'o, 'a> { + out: &'o mut Output, + literals: Stream<'a>, + near: Stream<'a>, + lengths: Stream<'a>, + /// The remembered match distance. + distance: usize, +} + +impl Lz<'_, '_> { + /// Decode one token region of output `[start, end)` ("Token meanings"). + fn region( + &mut self, + commands: Stream<'_>, + mut far: Stream<'_>, + start: usize, + end: usize, + ) -> Result<(), ReplayError> { + for (i, &token) in commands.bytes.iter().enumerate() { + let at = commands.at + i; + let token_len = usize::from(token); + match token { + 0x00 => { + let literals = self.length()? + 64; + self.literals(literals, end)?; + } + 0x01 => { + let len = self.length()? + 91; + self.distance = self.near.le16()?; + self.copy(len, end, at)?; + } + 0x02 => { + let len = self.length()? + 29; + self.far(&mut far, start)?; + self.copy(len, end, at)?; + } + 0x09..=0x17 => { + self.far(&mut far, start)?; + self.copy(token_len + 5, end, at)?; + } + 0x20..=0x7f => { + self.literals(token_len & 7, end)?; + self.distance = self.near.le16()?; + self.copy((token_len >> 3) & 15, end, at)?; + } + 0x81..=0xff => { + self.literals(token_len & 7, end)?; + let len = (token_len >> 3) & 15; + if len > 0 { + self.copy(len, end, at)?; + } + } + _ => return Err(unsupported(at, format_args!("token {token:02x}"))), + } + } + far.finish()?; + self.literals(end - self.out.len, end) + } + + /// One length value ("Length values"). + fn length(&mut self) -> Result { + let first = usize::from(self.lengths.byte()?); + if first < 252 { + return Ok(first); + } + Ok(first + 4 * self.lengths.le16()?) + } + + /// Take the region's next far entry as the remembered distance ("Far + /// counts and entries"): it names absolute source `start - V`. + fn far(&mut self, far: &mut Stream<'_>, start: usize) -> Result<(), ReplayError> { + let at = far.at; + let back = far.le24()?; + if back > start { + return Err(corrupt( + at, + format_args!("far entry {back} reaches before the archive from {start}"), + )); + } + self.distance = self.out.len - (start - back); + Ok(()) + } + + /// Append `len` literals, which must end by `end`. + fn literals(&mut self, len: usize, end: usize) -> Result<(), ReplayError> { + let pos = self.out.len; + if len > end - pos { + return Err(corrupt( + self.literals.at, + format_args!("{len} literals cross the region end at {end}"), + )); + } + match self.literals.bytes.first_chunk::<8>() { + Some(&word) if len <= 8 => { + self.literals.take(len)?; + self.out.buf[pos..pos + 8].copy_from_slice(&word); + self.out.len += len; + } + _ => self.out.push(self.literals.take(len)?), + } + Ok(()) + } + + /// Append a `len`-byte match at the remembered distance, which must end + /// by `end`. The token at input `at` asked for it. + fn copy(&mut self, len: usize, end: usize, at: usize) -> Result<(), ReplayError> { + let (pos, distance) = (self.out.len, self.distance); + if len > end - pos { + return Err(corrupt( + at, + format_args!("a {len}-byte match crosses the region end at {end}"), + )); + } + if distance == 0 || distance > pos { + return Err(corrupt( + at, + format_args!("match distance {distance} at output {pos}"), + )); + } + if distance < MIN_DISTANCE { + return Err(unsupported(at, format_args!("match distance {distance}"))); + } + // With distance at least 8, each 8-byte word reads only bytes already + // final, so copying word by word repeats a match's own output when it + // is longer than its distance, as the format requires. + let (buf, from) = (&mut self.out.buf, pos - distance); + if len <= 16 { + copy_word(buf, from, pos); + copy_word(buf, from + 8, pos + 8); + } else if len <= distance { + buf.copy_within(from..from + len, pos); + } else { + for offset in (0..len).step_by(8) { + copy_word(buf, from + offset, pos + offset); + } + } + self.out.len += len; + Ok(()) + } +} + +fn copy_word(buf: &mut [u8], from: usize, to: usize) { + let word: [u8; 8] = buf[from..from + 8].try_into().expect("8 bytes"); + buf[to..to + 8].copy_from_slice(&word); +} + +/// One consecutive field of the archive, read front to back. +struct Stream<'a> { + bytes: &'a [u8], + /// Archive input position of `bytes[0]`. + at: usize, + name: &'static str, +} + +impl<'a> Stream<'a> { + fn new(bytes: &'a [u8], at: usize, name: &'static str) -> Self { + Self { bytes, at, name } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], ReplayError> { + let Some((head, rest)) = self.bytes.split_at_checked(len) else { + return Err(corrupt( + self.at, + format_args!( + "the {} stream needs {len} bytes, {} remain", + self.name, + self.bytes.len() + ), + )); + }; + self.bytes = rest; + self.at += len; + Ok(head) + } + + fn array(&mut self) -> Result<&'a [u8; N], ReplayError> { + Ok(self.take(N)?.try_into().expect("took N bytes")) + } + + /// The next `len` bytes as their own stream. + fn sub(&mut self, len: usize, name: &'static str) -> Result, ReplayError> { + let at = self.at; + Ok(Stream::new(self.take(len)?, at, name)) + } + + /// Everything left, as its own stream. + fn rest(&mut self, name: &'static str) -> Stream<'a> { + let rest = Stream::new(self.bytes, self.at, name); + self.at += self.bytes.len(); + self.bytes = &[]; + rest + } + + /// The first `len` bytes and the rest, as two streams. + fn split(self, len: usize) -> (Stream<'a>, Stream<'a>) { + let (head, tail) = self.bytes.split_at(len); + ( + Stream::new(head, self.at, self.name), + Stream::new(tail, self.at + len, self.name), + ) + } + + fn byte(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + fn le16(&mut self) -> Result { + Ok(usize::from(u16::from_le_bytes(*self.array()?))) + } + + fn le24(&mut self) -> Result { + let &[a, b, c] = self.array()?; + Ok(usize::from(a) | usize::from(b) << 8 | usize::from(c) << 16) + } + + fn be24(&mut self) -> Result { + let &[a, b, c] = self.array()?; + Ok(usize::from(a) << 16 | usize::from(b) << 8 | usize::from(c)) + } + + /// Every byte must have been consumed. + fn finish(&self) -> Result<(), ReplayError> { + if self.bytes.is_empty() { + return Ok(()); + } + Err(corrupt( + self.at, + format_args!("{} unused {} bytes", self.bytes.len(), self.name), + )) + } +} + +#[cold] +fn corrupt(at: usize, what: impl Display) -> ReplayError { + ReplayError::new( + ErrorCode::Corrupt, + format!("Oodle archive byte {at}: {what}"), + ) +} + +#[cold] +fn unsupported(at: usize, what: impl Display) -> ReplayError { + ReplayError::new( + ErrorCode::UnsupportedCompression, + format!("Oodle archive byte {at}: {what} is not supported"), + ) +} + +#[cfg(test)] +mod tests { + //! Archives built field by field from the spec's layout, carrying the + //! token, offset, length and literal bytes of its worked examples. + + use super::*; + + fn hex(text: &str) -> Vec { + let digits: Vec = text.bytes().filter(u8::is_ascii_hexdigit).collect(); + digits + .chunks(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + + fn be24(value: usize) -> [u8; 3] { + let [_, a, b, c] = u32::try_from(value).unwrap().to_be_bytes(); + [a, b, c] + } + + fn le24(value: usize) -> [u8; 3] { + let [a, b, c, _] = u32::try_from(value).unwrap().to_le_bytes(); + [a, b, c] + } + + /// A length value in its one- or three-byte form. + fn length(value: usize) -> Vec { + if value < 252 { + return vec![value as u8]; + } + let first = 252 + (value - 252) % 4; + let mut bytes = vec![first as u8]; + bytes.extend(u16::try_from((value - first) / 4).unwrap().to_le_bytes()); + bytes + } + + /// Bytes that differ position to position, so a copy from the wrong + /// place shows. + fn pattern(len: usize, seed: u32) -> Vec { + (0..len as u32) + .map(|i| (i.wrapping_add(seed << 20).wrapping_mul(2_654_435_761) >> 24) as u8) + .collect() + } + + /// The fields of one mode-1 LZ chunk, in the spec's order. + #[derive(Clone, Default)] + struct LzChunk { + initial: Vec, + literals: Vec, + commands: Vec, + /// First-region command count, written for chunks over 64 KiB. + split: Option, + near: Vec, + far: [Vec; 2], + lengths: Vec, + } + + impl LzChunk { + fn encode(&self) -> Vec { + let mut bytes = self.initial.clone(); + for array in [&self.literals, &self.commands] { + bytes.extend(be24(array.len())); + bytes.extend(array); + } + if let Some(split) = self.split { + bytes.extend(split.to_le_bytes()); + } + bytes.extend(u16::try_from(self.near.len()).unwrap().to_le_bytes()); + for distance in &self.near { + bytes.extend(distance.to_le_bytes()); + } + bytes.extend(le24(self.far[0].len() << 12 | self.far[1].len())); + for &back in self.far.iter().flatten() { + bytes.extend(le24(back)); + } + bytes.extend(&self.lengths); + bytes + } + + /// A long-near token at distance 8, `len` bytes long. + fn fill(&mut self, len: usize) { + self.commands.push(0x01); + self.near.push(8); + self.lengths.extend(length(len - 91)); + } + + /// The whole archive when this is its only chunk. + fn archive(&self) -> Vec { + block(0x8c, &[chunk(1, &self.encode())]) + } + + /// Archive position of the literal header in [`Self::archive`]. + fn literal_header_at(&self) -> usize { + 2 + 3 + 3 + self.initial.len() + } + + /// Archive position of the near-entry count in [`Self::archive`], + /// for a one-region chunk. + fn near_count_at(&self) -> usize { + self.literal_header_at() + 3 + self.literals.len() + 3 + self.commands.len() + } + } + + fn chunk(mode: usize, payload: &[u8]) -> Vec { + let mut bytes = be24(0x80_0000 | mode << 19 | payload.len()).to_vec(); + bytes.extend(payload); + bytes + } + + fn block(header: u8, chunks: &[Vec]) -> Vec { + let body = chunks.concat(); + let mut bytes = vec![header, 0x0a]; + bytes.extend(be24(body.len() - 1)); + bytes.extend(body); + bytes + } + + /// `out[at..end]` repeats the bytes `distance` before it. + fn repeats(out: &[u8], at: usize, end: usize, distance: usize) -> bool { + (at..end).all(|i| out[i] == out[i - distance]) + } + + // Worked examples ("Worked examples from the corpus"). + + /// Initial bytes, `8e` (compact reuse) and `87` (literal only), from + /// 1fb53a2c_1_1. + fn compact_reuse() -> (LzChunk, usize) { + let mut lz = LzChunk { + initial: hex("00 00 00 00 82 32 00 3c"), + literals: hex("47 04 02 02 43 00 00 2f 47 61 6d 65 2f"), + commands: vec![0x8e, 0x87], + ..Default::default() + }; + lz.fill(300); + (lz, 8 + 7 + 7 + 300) + } + + #[test] + fn initial_bytes_then_compact_reuse_and_literal_only_tokens() { + let (lz, size) = compact_reuse(); + let out = decode(&lz.archive(), size).unwrap(); + assert_eq!(out[..8], hex("00 00 00 00 82 32 00 3c")); + // 8e: six literals, then one byte from output[6] at distance 8. + assert_eq!(out[8..15], hex("47 04 02 02 43 00 00")); + // 87: seven literals, no match, nothing else consumed. + assert_eq!(out[15..22], hex("00 2f 47 61 6d 65 2f")); + assert!(repeats(&out, 22, size, 8)); + } + + /// `7d` (compact new distance) from 1fb53a2c_1_1 at output 55, then `87` + /// and `90`, which reuses the distance `7d` set. + fn compact_new() -> (LzChunk, usize) { + let mut literals = vec![0; 31]; // output [8, 39) + literals.extend(hex("42 61 73 65 52 65 70 6c 61 79 43 6f 6e 74 72")); // [39, 54) + literals.push(0x99); // 54 + literals.extend(hex("6c 6c 65 72 2e")); + literals.extend(pattern(7, 1)); + let mut lz = LzChunk { + initial: pattern(8, 0), + literals, + commands: vec![0x87, 0x87, 0x87, 0x87, 0x87, 0x87, 0x85, 0x7d, 0x87, 0x90], + near: vec![u16::from_le_bytes([0x15, 0x00])], + ..Default::default() + }; + lz.fill(100); + (lz, 55 + 20 + 7 + 2 + 100) + } + + #[test] + fn compact_new_distance_token() { + let (lz, size) = compact_new(); + let out = decode(&lz.archive(), size).unwrap(); + assert_eq!( + out[55..75], + hex("6c 6c 65 72 2e 42 61 73 65 52 65 70 6c 61 79 43 6f 6e 74 72") + ); + // 87 keeps D = 21; 90 copies two bytes at it. + assert_eq!(out[82..84], out[61..63]); + } + + #[test] + fn a_match_longer_than_its_distance_repeats_its_own_output() { + // `48` with near `08 00`: nine bytes at distance 8 (1fb53a2c_1_1). + let mut lz = LzChunk { + initial: (1..=8).collect(), + commands: vec![0x48], + near: vec![8], + ..Default::default() + }; + lz.fill(100); + let out = decode(&lz.archive(), 117).unwrap(); + assert_eq!(out[8..17], [1, 2, 3, 4, 5, 6, 7, 8, 1]); + } + + const LONG_LITERAL: &str = "7b0a9728fbba85210984c7e3ac4412981cdb44598090b58127fedb3c8bd07b13\ + c7eda7209983eba89273c1958ebabd52efde9834a71b294df19f99d16c469cef\ + eebc011a26f6e16e9247596f1e4122ca11985321aa6c0244000000d30a32c707\ + b5029f2806b0d7adb1b16890a66c8312b7ea362871ab708312b72a372871ab74\ + 8312b76a372871ab788312b7aa372871ab7c8312b7ea372871ab008412b702"; + const LONG_NEAR: &[u8; 95] = + b"\0\0\0/Game/Environment/Plummet/Blueprints/RespawningPlummetShootable.RespawningPlummetShootable_C"; + + /// `00` with length `5f` (159 literals), a second `00` with a three-byte + /// length, `01` with length `04` and near `a5 0f` (95 bytes at 4005), and + /// `01` with length `fd d6 00` and near `1b 00` (1200 bytes at 27), from + /// 1fb53a2c_1_1 and 1fb53a2c_1_12. + fn long_literal_and_near() -> (LzChunk, usize) { + let source = 4300 - 4005 - 167; // within the second literal run + let mut run = pattern(4300 - 167, 2); + run[source..source + 95].copy_from_slice(LONG_NEAR); + let mut literals = hex(LONG_LITERAL); + literals.extend(run); + let mut lengths = hex("5f"); + lengths.extend(length(4300 - 167 - 64)); + lengths.extend(hex("04 fd d6 00")); + let lz = LzChunk { + initial: pattern(8, 3), + literals, + commands: vec![0x00, 0x00, 0x01, 0x01], + near: vec![ + u16::from_le_bytes([0xa5, 0x0f]), + u16::from_le_bytes([0x1b, 0x00]), + ], + lengths, + ..Default::default() + }; + (lz, 4300 + 95 + 1200) + } + + #[test] + fn long_literal_and_long_near_tokens_with_both_length_forms() { + let (lz, size) = long_literal_and_near(); + let out = decode(&lz.archive(), size).unwrap(); + assert_eq!(out[8..167], hex(LONG_LITERAL)); + assert_eq!(out[4300..4395], LONG_NEAR[..]); + // fd d6 00 = 253 + 4 * 214: a 1200-byte match at distance 27. + assert!(repeats(&out, 4395, size, 27)); + } + + const SHORT_FAR: &str = "2f41737361756c745269666c655f"; + const LONG_FAR: &str = "4d6f6465732f436f6d6d6f6e2f426173655265706c6179506c617965725374617465"; + const LONG_LITERAL_PREFIX: &str = + "530032a70c041206477117e099c35d00807f08168d2d7b8c636ccd7145832de3"; + + /// Far tokens in a second region at 65536 (1fb53a2c_1_1, 1fb53a2c_2_13): + /// `00` with `fe 25 02` (2514 literals), `09` with far `fe c8 00`, `02` + /// with length `05` and far `ac f4 00`, `88` reusing that distance, and + /// `02` with length `ff 01 00` and far `e0 e8 00`. The first region is + /// all trailing literals. + fn far_tokens() -> (LzChunk, usize) { + let mut literals = pattern(REGION - 8, 4); + literals[14082 - 8..14082 - 8 + 14].copy_from_slice(&hex(SHORT_FAR)); + literals[2900 - 8..2900 - 8 + 34].copy_from_slice(&hex(LONG_FAR)); + literals.extend(hex(LONG_LITERAL_PREFIX)); + literals.extend(pattern(2514 - 32, 5)); + literals.extend(pattern(92037 - 68050 + 100, 6)); // second run, then the tail + let mut lengths = hex("fe 25 02"); + lengths.extend(length(92037 - 68050 - 64)); + lengths.extend(hex("05 ff 01 00")); + let far = |bytes: &str| { + let b = hex(bytes); + usize::from(b[0]) | usize::from(b[1]) << 8 | usize::from(b[2]) << 16 + }; + let lz = LzChunk { + initial: pattern(8, 7), + literals, + commands: vec![0x00, 0x00, 0x09, 0x02, 0x88, 0x02], + split: Some(0), + far: [ + vec![], + vec![far("fe c8 00"), far("ac f4 00"), far("e0 e8 00")], + ], + lengths, + ..Default::default() + }; + (lz, 92474) + } + + #[test] + fn far_tokens_name_sources_from_their_region_start() { + let (lz, size) = far_tokens(); + let out = decode(&lz.archive(), size).unwrap(); + assert_eq!(out[65536..65568], hex(LONG_LITERAL_PREFIX)); + // 09: 14 bytes from 65536 - 51454 = 14082, not from P - 51454. + assert_eq!(out[92037..92051], hex(SHORT_FAR)); + // 02 + 05: 34 bytes from 65536 - 62636 = 2900. + assert_eq!(out[92051..92085], hex(LONG_FAR)); + // 88 reuses D = 92051 - 2900. + assert_eq!(out[92085], out[92085 - (92051 - 2900)]); + // 02 + ff 01 00: 288 bytes from 65536 - 59616. + assert_eq!(out[92086..92374], out[5920..6208]); + } + + /// Two LZ chunks, a raw chunk, a raw block, then a compressed block whose + /// far matches reach back into both raw spans. Also returns where that + /// last block starts. + fn raw_storage() -> (Vec, usize, usize) { + let mut lz = LzChunk { + initial: pattern(8, 8), + split: Some(1), + ..Default::default() + }; + lz.fill(REGION - 8); + lz.fill(REGION); + let mut archive = block( + 0x8c, + &[chunk(1, &lz.encode()), chunk(0, &pattern(CHUNK, 9))], + ); + archive.extend([0x4c, 0x0a]); + archive.extend(pattern(BLOCK, 10)); + let last_block = archive.len(); + let mut last = LzChunk { + commands: vec![0x09, 0x02], + far: [vec![2 * BLOCK - 300_000, 2 * BLOCK - 140_000], vec![]], + lengths: vec![0], + ..Default::default() + }; + last.fill(1000 - 14 - 29); + archive.extend(block(0x0c, &[chunk(1, &last.encode())])); + (archive, 2 * BLOCK + 1000, last_block) + } + + #[test] + fn raw_chunks_and_raw_blocks_join_the_history() { + let (archive, size, _) = raw_storage(); + let out = decode(&archive, size).unwrap(); + assert!(repeats(&out, 8, CHUNK, 8)); + assert_eq!(out[CHUNK..BLOCK], pattern(CHUNK, 9)); + assert_eq!(out[BLOCK..2 * BLOCK], pattern(BLOCK, 10)); + assert_eq!(out[2 * BLOCK..2 * BLOCK + 14], out[300_000..300_014]); + assert_eq!(out[2 * BLOCK + 14..2 * BLOCK + 43], out[140_000..140_029]); + assert!(repeats(&out, 2 * BLOCK + 43, size, 8)); + } + + #[test] + fn selkie_is_this_decoder() { + let (lz, size) = compact_reuse(); + assert_eq!( + Selkie.decompress(&lz.archive(), size), + decode(&lz.archive(), size) + ); + } + + // Rejections ("Reject table", "End conditions and consistency checks"). + + const CORRUPT: ErrorCode = ErrorCode::Corrupt; + const UNSUPPORTED: ErrorCode = ErrorCode::UnsupportedCompression; + + fn code(archive: &[u8], size: usize) -> ErrorCode { + decode(archive, size).expect_err("an error").code + } + + /// The error from [`compact_reuse`]'s archive with `edit` applied. + fn edited(edit: impl FnOnce(&mut Vec)) -> ErrorCode { + let (lz, size) = compact_reuse(); + let mut archive = lz.archive(); + edit(&mut archive); + code(&archive, size) + } + + /// The error from [`compact_reuse`]'s fields with `edit` applied. + fn rebuilt(edit: impl FnOnce(&mut LzChunk)) -> ErrorCode { + let (mut lz, size) = compact_reuse(); + edit(&mut lz); + code(&lz.archive(), size) + } + + #[test] + fn every_truncation_is_corrupt() { + let (lz, size) = compact_reuse(); + let archive = lz.archive(); + for len in 0..archive.len() { + assert_eq!(code(&archive[..len], size), CORRUPT, "{len} bytes"); + } + } + + #[test] + fn trailing_input_and_wrong_sizes_are_corrupt() { + let (lz, size) = compact_reuse(); + let archive = lz.archive(); + assert_eq!(code(&archive, size - 1), CORRUPT); + assert_eq!(code(&archive, size + 1), CORRUPT); + assert_eq!(code(&archive, 0), UNSUPPORTED, "an empty archive"); + assert_eq!(edited(|a| a.push(0)), CORRUPT); + } + + #[test] + fn block_headers_outside_the_profile_are_refused() { + assert_eq!(edited(|a| a[1] = 0x0b), UNSUPPORTED, "decoder 11"); + assert_eq!(edited(|a| a[1] = 0x08), UNSUPPORTED, "decoder 8"); + assert_eq!(edited(|a| a[1] = 0x8a), UNSUPPORTED, "checksum"); + assert_eq!(edited(|a| a[0] = 0x8d), CORRUPT, "signature"); + assert_eq!(edited(|a| a[0] = 0x9c), CORRUPT, "reserved bits"); + assert_eq!( + edited(|a| a[0] = 0x0c), + UNSUPPORTED, + "first block without restart" + ); + assert_eq!(edited(|a| a[0] = 0xcc), UNSUPPORTED, "raw first block"); + let (mut archive, size, last_block) = raw_storage(); + archive[last_block] = 0x8c; + assert_eq!(code(&archive, size), UNSUPPORTED, "restart past output 0"); + } + + #[test] + fn quantum_headers_outside_the_profile_are_refused() { + let (_, size) = compact_reuse(); + let quantum = |header: usize| edited(|a| a[2..5].copy_from_slice(&be24(header))); + assert_eq!( + quantum(size - 1), + UNSUPPORTED, + "payload as large as the block" + ); + assert_eq!(quantum(size), CORRUPT, "payload larger than the block"); + assert_eq!(quantum(0x07_ffff), UNSUPPORTED, "fill quantum"); + assert_eq!(quantum(0x03_ffff), UNSUPPORTED, "special quantum"); + assert_eq!(quantum(0x04_0000 | 50), UNSUPPORTED, "upper bits"); + } + + #[test] + fn chunk_headers_outside_the_profile_are_refused() { + let chunk_header = |set: u8, clear: u8| { + edited(|a| { + a[5] |= set; + a[5] &= !clear; + }) + }; + assert_eq!(chunk_header(0, 0x80), UNSUPPORTED, "standalone byte array"); + assert_eq!(chunk_header(0, 0x08), UNSUPPORTED, "mode 0, short"); + assert_eq!(chunk_header(0x10, 0x08), UNSUPPORTED, "mode 2"); + assert_eq!(chunk_header(0x04, 0), CORRUPT, "larger than its output"); + // One raw chunk makes the quantum as large as its output: a raw + // quantum, which this profile does not carry. + let raw = block(0x8c, &[chunk(0, &[7; 100])]); + assert_eq!(code(&raw, 103), UNSUPPORTED); + } + + #[test] + fn byte_array_headers_outside_the_profile_are_refused() { + let at = compact_reuse().0.literal_header_at(); + let header = |byte: u8| edited(|a| a[at] = byte); + assert_eq!(header(0x80), UNSUPPORTED, "short header"); + assert_eq!(header(0x10), UNSUPPORTED, "entropy-coded"); + assert_eq!(header(0x50), UNSUPPORTED, "entropy-coded"); + assert_eq!(header(0x03), CORRUPT, "more literals than output"); + } + + #[test] + fn stream_counts_outside_the_profile_are_refused() { + let lz = compact_reuse().0; + let near = lz.near_count_at(); + assert_eq!( + edited(|a| a[near..near + 2].fill(0xff)), + UNSUPPORTED, + "split near stream" + ); + let far = near + 2 + 2 * lz.near.len(); + assert_eq!( + edited(|a| a[far..far + 3].copy_from_slice(&le24(0xfff << 12))), + UNSUPPORTED + ); + assert_eq!( + edited(|a| a[far..far + 3].copy_from_slice(&le24(0xfff))), + UNSUPPORTED + ); + assert_eq!( + rebuilt(|lz| lz.far[1].push(0)), + CORRUPT, + "second-region far list in one region" + ); + // A first-region command count beyond the commands. + let (mut lz, size) = far_tokens(); + lz.split = Some(7); + assert_eq!(code(&lz.archive(), size), CORRUPT); + } + + #[test] + fn unobserved_tokens_are_refused() { + for token in [0x80, 0x03, 0x08, 0x18, 0x1f] { + assert_eq!( + rebuilt(|lz| lz.commands.insert(0, token)), + UNSUPPORTED, + "{token:02x}" + ); + } + } + + #[test] + fn leftover_or_missing_stream_bytes_are_corrupt() { + assert_eq!(rebuilt(|lz| lz.literals.push(0)), CORRUPT, "spare literal"); + assert_eq!( + rebuilt(|lz| { + lz.literals.pop(); + }), + CORRUPT, + "missing literal" + ); + assert_eq!(rebuilt(|lz| lz.near.push(8)), CORRUPT, "spare near entry"); + assert_eq!( + rebuilt(|lz| { + lz.near.pop(); + }), + CORRUPT, + "missing near entry" + ); + assert_eq!(rebuilt(|lz| lz.lengths.push(0)), CORRUPT, "spare length"); + assert_eq!(rebuilt(|lz| lz.far[0].push(0)), CORRUPT, "spare far entry"); + assert_eq!( + rebuilt(|lz| lz.commands.push(0x87)), + CORRUPT, + "past the region end" + ); + } + + #[test] + fn matches_out_of_range_are_refused() { + let near = |distance: u16| rebuilt(|lz| *lz.near.last_mut().unwrap() = distance); + assert_eq!(near(23), CORRUPT, "before output 0"); + assert_eq!(near(4), UNSUPPORTED, "distance below 8"); + assert_eq!(near(0), CORRUPT, "distance 0"); + let (mut lz, size) = compact_reuse(); + *lz.near.last_mut().unwrap() = 22; + assert!( + decode(&lz.archive(), size).is_ok(), + "distance reaching exactly output 0" + ); + // Far entries are relative to the region start, here 0. + let far = |back: usize| { + rebuilt(|lz| { + lz.commands.insert(2, 0x09); + lz.far[0].push(back); + }) + }; + assert_eq!(far(1), CORRUPT, "far entry before output 0"); + // The match crossing the region end. + assert_eq!(rebuilt(|lz| lz.lengths[0] += 1), CORRUPT); + } + + #[test] + fn regions_from_0xbfffff_on_are_refused() { + // One compressed block, then raw blocks up to output 48 * 256 KiB = + // 0xc00000, then a compressed block there. + let mut lz = LzChunk { + initial: vec![0; 8], + split: Some(1), + ..Default::default() + }; + lz.fill(REGION - 8); + lz.fill(REGION); + let mut archive = block(0x8c, &[chunk(1, &lz.encode()), chunk(0, &vec![0; CHUNK])]); + for _ in 1..48 { + archive.extend([0x4c, 0x0a]); + archive.extend(vec![0; BLOCK]); + } + let mut last = LzChunk::default(); + last.fill(1000); + archive.extend(block(0x0c, &[chunk(1, &last.encode())])); + assert_eq!(48 * BLOCK, 0xc0_0000); + assert_eq!(code(&archive, 48 * BLOCK + 1000), UNSUPPORTED); + } + + // Hostile input must come back as an error, never a panic or a hang. + + /// xorshift64*: deterministic, no dependency. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + } + + /// Valid archives to mutate: the worked examples, plus the quick set when + /// `ICARUS_OODLE_VECTORS` names the vectors directory. + fn seeds() -> Vec<(Vec, usize)> { + let mut seeds = vec![]; + for (lz, size) in [ + compact_reuse(), + compact_new(), + long_literal_and_near(), + far_tokens(), + ] { + seeds.push((lz.archive(), size)); + } + let (archive, size, _) = raw_storage(); + seeds.push((archive, size)); + if let Some(dir) = std::env::var_os("ICARUS_OODLE_VECTORS") { + let dir = std::path::Path::new(&dir); + for id in ["2dd2de86_1_1", "b63bb117_1_1", "1fb53a2c_2_2"] { + let input = std::fs::read(dir.join(format!("{id}.in"))).unwrap(); + let size = std::fs::metadata(dir.join(format!("{id}.out"))) + .unwrap() + .len(); + seeds.push((input, size as usize)); + } + } + seeds + } + + #[test] + fn mutated_archives_never_panic() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for (seed, size) in seeds() { + assert_eq!(decode(&seed, size).map(|out| out.len()), Ok(size)); + for _ in 0..400 { + let mut archive = seed.clone(); + // Damage the headers often: they steer everything after them. + for _ in 0..1 + rng.below(4) { + let at = if rng.below(2) == 0 { + rng.below(64.min(archive.len())) + } else { + rng.below(archive.len()) + }; + archive[at] = rng.next() as u8; + } + if rng.below(4) == 0 { + archive.truncate(rng.below(archive.len())); + } + let size = match rng.below(4) { + 0 => rng.below(2 * size + 1), + _ => size, + }; + if let Ok(out) = decode(&archive, size) { + assert_eq!(out.len(), size); + } + } + } + } + + #[test] + fn random_bytes_never_panic() { + let mut rng = Rng(42); + for _ in 0..20_000 { + let mut archive: Vec = (0..rng.below(300)).map(|_| rng.next() as u8).collect(); + if archive.len() >= 2 && rng.below(2) == 0 { + archive[..2].copy_from_slice(&[0x8c, 0x0a]); + } + let _ = decode(&archive, 1 + rng.below(1 << 20)); + } + } +} diff --git a/native/replay/tests/selkie_vectors.rs b/native/replay/tests/selkie_vectors.rs new file mode 100644 index 000000000..fbae986b6 --- /dev/null +++ b/native/replay/tests/selkie_vectors.rs @@ -0,0 +1,85 @@ +//! Selkie against the recorded Oodle archives: set `ICARUS_OODLE_VECTORS` to +//! the vectors directory (skipped otherwise). Every archive in its +//! `manifest.json`, and the `quick.json` smoke set, must inflate to its +//! recorded output byte for byte. With `--features dev-vectors` the outputs +//! are also checked against the manifest's sha256. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use icarus_replay::oodle::Decompressor; +use icarus_replay::selkie::Selkie; + +fn vectors() -> Option { + let dir = std::env::var_os("ICARUS_OODLE_VECTORS"); + if dir.is_none() { + eprintln!("ICARUS_OODLE_VECTORS unset: Selkie vector test skipped"); + } + dir.map(PathBuf::from) +} + +/// Inflate every archive `list` names, panicking on the first mismatch. +/// Returns the output bytes and time spent decoding. +fn check_all(dir: &Path, list: &str) -> (usize, usize, Duration) { + let text = std::fs::read_to_string(dir.join(list)).expect("archive list"); + let json: serde_json::Value = serde_json::from_str(&text).expect("archive list JSON"); + let archives = json["archives"].as_array().expect("archives"); + let (mut bytes, mut elapsed) = (0, Duration::ZERO); + for archive in archives { + let id = archive["id"].as_str().expect("id"); + let size = archive["decompressedSize"].as_u64().expect("size") as usize; + let input = std::fs::read(dir.join(archive["input"]["file"].as_str().unwrap())).unwrap(); + let expected = + std::fs::read(dir.join(archive["output"]["file"].as_str().unwrap())).unwrap(); + assert_eq!( + input.len() as u64, + archive["compressedSize"].as_u64().unwrap(), + "{id} input" + ); + let started = Instant::now(); + let output = Selkie + .decompress(&input, size) + .unwrap_or_else(|e| panic!("{id}: {e}")); + elapsed += started.elapsed(); + assert_eq!(output.len(), size, "{id} length"); + #[cfg(feature = "dev-vectors")] + assert_eq!( + sha256_hex(&output), + archive["output"]["sha256"].as_str().unwrap(), + "{id} sha256" + ); + if let Some(at) = output.iter().zip(&expected).position(|(a, b)| a != b) { + panic!("{id}: first differing output byte at {at}"); + } + assert_eq!(output.len(), expected.len(), "{id} recorded length"); + bytes += size; + } + (archives.len(), bytes, elapsed) +} + +#[test] +fn every_recorded_archive_inflates_byte_for_byte() { + let Some(dir) = vectors() else { return }; + let (archives, bytes, elapsed) = check_all(&dir, "manifest.json"); + eprintln!( + "Selkie: {archives} archives, {bytes} bytes in {elapsed:?} ({:.0} MB/s)", + bytes as f64 / 1e6 / elapsed.as_secs_f64() + ); + assert_eq!(archives, 259); +} + +#[test] +fn the_quick_set_inflates_byte_for_byte() { + let Some(dir) = vectors() else { return }; + let (archives, _, _) = check_all(&dir, "quick.json"); + assert_eq!(archives, 3); +} + +#[cfg(feature = "dev-vectors")] +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} From 87ba7063723d50ae3d7575eddf3c5f1e79e36b91 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 00:59:37 -0400 Subject: [PATCH 04/18] Harden the replay viewer after review Reading: nullable facts stay null, non-finite numbers and malformed buffers fail as bad format, every opening failure reaches the error screen, cancel holds across awaits, one bad file never hides the list. Playback: alive/dead follows whichever of round start, health and death came last; quiet streams hold a living player in place; the dead stay where they fell; speed carries fractional milliseconds; the score counts the round being watched once it ends. Capture: refuses non-finite frames before writing, starts a new strategy if the old one was deleted or moved to another map, and Open lands on the page just captured. The replay reclaims the editor's map and marker settings when the user comes back from the editor. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/providers/replay_library_provider.dart | 36 ++- lib/providers/strategy_provider.dart | 8 +- lib/replay/replay_ability_catalog.dart | 5 +- lib/replay/replay_agents.dart | 4 +- lib/replay/replay_capture.dart | 52 ++++- lib/replay/replay_document.dart | 105 ++++++--- lib/replay/replay_files.dart | 62 +++-- lib/replay/replay_frame.dart | 65 ++++-- lib/replay/replay_loader.dart | 49 ++-- lib/replay/replay_playback.dart | 11 +- lib/replay_view.dart | 93 +++++--- lib/strategy_view.dart | 10 +- lib/widgets/replay/replay_canvas.dart | 2 +- lib/widgets/replay/replay_dock.dart | 2 +- lib/widgets/replay/replay_match_card.dart | 45 ++-- lib/widgets/replay/replay_roster.dart | 38 ++- test/replay_capture_test.dart | 141 ++++++++++++ test/replay_document_test.dart | 83 +------ test/replay_frame_test.dart | 256 +++++++++++++++++++++ test/replay_test_support.dart | 88 +++++++ 20 files changed, 903 insertions(+), 252 deletions(-) create mode 100644 test/replay_capture_test.dart create mode 100644 test/replay_frame_test.dart create mode 100644 test/replay_test_support.dart diff --git a/lib/providers/replay_library_provider.dart b/lib/providers/replay_library_provider.dart index 51109b532..16d82cc0a 100644 --- a/lib/providers/replay_library_provider.dart +++ b/lib/providers/replay_library_provider.dart @@ -30,26 +30,40 @@ final replayLibraryProvider = ); class ReplayLibrary extends AsyncNotifier> { - /// Headers already read, by file cache key: a refresh only probes new files. - final _probes = {}; + /// What each file's header said, by file cache key, so a refresh only + /// probes files it hasn't seen. The listing itself is rebuilt each time + /// from the file as it is now. + final _probes = {}; @override Future> build() => _load(); Future> _load() async { final files = await ref.read(replayFilesProvider).list(); - final listings = []; - for (final file in files) { - listings.add(_probes[file.cacheKey] ??= await _probe(file)); - } - return listings; + return [for (final file in files) await _listing(file)]; + } + + Future _listing(ReplayFile file) async { + final header = _probes[file.cacheKey] ??= await _probe(file); + return ReplayListing(file: file, probe: header.probe, error: header.error); } - Future _probe(ReplayFile file) async { + /// One unreadable file is listed as such; it never hides the others. + Future<({ReplayProbe? probe, ReplayDecodeException? error})> _probe( + ReplayFile file, + ) async { try { - return ReplayListing(file: file, probe: await probeReplay(file.path)); + return (probe: await probeReplay(file.path), error: null); } on ReplayDecodeException catch (error) { - return ReplayListing(file: file, error: error); + return (probe: null, error: error); + } catch (error) { + return ( + probe: null, + error: const ReplayDecodeException( + 'corrupt', + "This file couldn't be read.", + ), + ); } } @@ -61,6 +75,6 @@ class ReplayLibrary extends AsyncNotifier> { Future import(String path) async { final file = await ref.read(replayFilesProvider).import(path); await refresh(); - return _probes[file.cacheKey] ?? await _probe(file); + return _listing(file); } } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 903b05271..18137997a 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1545,7 +1545,8 @@ class StrategyProvider extends Notifier { await setActivePageAnimated(newPage.id); } - Future loadFromHive(String id) async { + /// Opens strategy [id] on its first page, or on [pageId] when given. + Future loadFromHive(String id, {String? pageId}) async { cancelPendingSave(); final newStrat = Hive.box(HiveBoxNames.strategiesBox) .values @@ -1577,7 +1578,10 @@ class StrategyProvider extends Notifier { // We clear previous data to avoid artifacts when loading a new strategy final migratedStrategy = migrateToCurrentVersion(newStrat); - final page = migratedStrategy.pages.first; + final page = migratedStrategy.pages.firstWhere( + (page) => page.id == pageId, + orElse: () => migratedStrategy.pages.first, + ); if (migratedStrategy != newStrat) { await Hive.box(HiveBoxNames.strategiesBox) diff --git a/lib/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart index 6d7eb436d..38c0f1d18 100644 --- a/lib/replay/replay_ability_catalog.dart +++ b/lib/replay/replay_ability_catalog.dart @@ -193,7 +193,10 @@ class _FromStart extends _Placement { required double mapScale, }) { final points = utility.points; - final startPoint = points.length >= 2 ? points.first : utility.position; + // A shape drawn by its points stays put; otherwise the start travels + // with the utility (Paranoia, a moving cover). + final startPoint = + points.length >= 2 ? points.first : utility.positionAt(timeMs); final start = projection.toWorld(startPoint.x, startPoint.y); final endPoint = points.isEmpty ? null : points.last; final end = diff --git a/lib/replay/replay_agents.dart b/lib/replay/replay_agents.dart index 555c67964..5ce8ad016 100644 --- a/lib/replay/replay_agents.dart +++ b/lib/replay/replay_agents.dart @@ -35,5 +35,5 @@ const Map replayAgentsById = { '7f94d92c-4234-0a36-9646-3a87eb8b5c89': AgentType.yoru, }; -AgentType? replayAgentType(String agentId) => - replayAgentsById[agentId.toLowerCase()]; +AgentType? replayAgentType(String? agentId) => + agentId == null ? null : replayAgentsById[agentId.toLowerCase()]; diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart index 70ffb149c..e53462632 100644 --- a/lib/replay/replay_capture.dart +++ b/lib/replay/replay_capture.dart @@ -1,7 +1,7 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/replay/replay_frame.dart'; @@ -14,9 +14,15 @@ import 'package:uuid/uuid.dart'; /// Players and utility keep their replay ids on every page, so the editor's /// page transitions move each player from one captured moment to the next. class ReplayCapture { - ReplayCapture({required this.ref, required this.map, required this.name}); + ReplayCapture({ + required this.createStrategy, + required this.map, + required this.name, + }); - final WidgetRef ref; + /// Makes the empty strategy the first capture fills, as the library's + /// New Strategy does. + final Future Function(MapValue map, String name) createStrategy; final MapValue map; /// The strategy's name when the first capture creates it. @@ -24,22 +30,31 @@ class ReplayCapture { String? _strategyId; - /// The strategy captures go to, once there is one. + /// The page the last capture added. + String? get lastPageId => _lastPageId; + String? _lastPageId; + + /// The strategy captures go to, while it still exists on this map. If the + /// user deleted it or moved it to another map, the next capture starts a + /// new one rather than writing this map's positions into it. StrategyData? get strategy { final id = _strategyId; if (id == null) return null; - return Hive.box(HiveBoxNames.strategiesBox).get(id); + final strategy = Hive.box(HiveBoxNames.strategiesBox).get(id); + return strategy?.mapData == map ? strategy : null; } /// Saves [frame] as a page named [pageName] and returns the strategy. - Future capture(ReplayFrame frame, - {required String pageName}) async { + /// Refuses, without writing, a frame holding a non-finite number. + Future capture( + ReplayFrame frame, { + required String pageName, + }) async { + _checkFinite(frame); final box = Hive.box(HiveBoxNames.strategiesBox); var target = strategy; final replaceBlankPage = target == null; - target ??= await ref - .read(strategyProvider.notifier) - .createNewStrategy(map: map, name: name); + target ??= await createStrategy(map, name); _strategyId = target.id; final pages = [...target.pages] @@ -65,6 +80,23 @@ class ReplayCapture { lastEdited: DateTime.now(), ); await box.put(updated.id, updated); + _lastPageId = page.id; return updated; } + + static void _checkFinite(ReplayFrame frame) { + bool finite(double value) => value.isFinite; + for (final widget in frame.widgets) { + final rotation = switch (widget) { + PlacedViewConeAgent(:final rotation) => rotation, + PlacedAbility(:final rotation) => rotation, + _ => 0.0, + }; + if (!finite(widget.position.dx) || + !finite(widget.position.dy) || + !finite(rotation)) { + throw StateError('Capture refused: ${widget.id} is not on the map.'); + } + } + } } diff --git a/lib/replay/replay_document.dart b/lib/replay/replay_document.dart index 4606b69cf..c1ee9a763 100644 --- a/lib/replay/replay_document.dart +++ b/lib/replay/replay_document.dart @@ -56,19 +56,21 @@ class ReplayDocument { if (jsonEnd > bytes.length) { throw const FormatException('Decoded replay is truncated.'); } - final json = - jsonDecode(utf8.decode(Uint8List.sublistView(bytes, 12, jsonEnd))) - as Map; final blobStart = (jsonEnd + 7) & ~7; final blob = blobStart <= bytes.length ? ByteData.sublistView(bytes, blobStart) : ByteData(0); try { + final json = + jsonDecode(utf8.decode(Uint8List.sublistView(bytes, 12, jsonEnd))) + as Map; return ReplayDocument._fromJson(json, blob); } on TypeError catch (error) { - // A field of the wrong type or missing: the buffer is not what this + // A field missing or of the wrong type: the buffer is not what this // reader understands, which callers handle like any bad format. throw FormatException('Decoded replay is malformed: $error'); + } on RangeError catch (error) { + throw FormatException('Decoded replay is malformed: $error'); } } @@ -178,16 +180,17 @@ class ReplayPlayer { final String subject; - /// valorant-api.com agent UUID. - final String agentId; - final ReplayTeam team; + /// valorant-api.com agent UUID, when the header names one. + final String? agentId; + + /// Null when no combat report names the player's team. + final ReplayTeam? team; final String? name; factory ReplayPlayer.fromJson(Map json) => ReplayPlayer( subject: json['subject'] as String, - agentId: (json['agentId'] as String).toLowerCase(), - team: ReplayTeam.tryParse(json['team']) ?? - (throw FormatException('Player ${json['subject']} has no team.')), + agentId: (json['agentId'] as String?)?.toLowerCase(), + team: ReplayTeam.tryParse(json['team']), name: json['name'] as String?, ); } @@ -213,7 +216,9 @@ class ReplayRound { final int startMs; final int? combatStartMs; final int endMs; - final ReplayTeam attackingTeam; + + /// Null when no round of this half has a result to tell sides apart. + final ReplayTeam? attackingTeam; final ReplayTeam? winningTeam; final ReplayRoundEnd? endReason; final ReplaySpikePlant? plant; @@ -238,10 +243,7 @@ class ReplayRound { startMs: json['startMs'] as int, combatStartMs: json['combatStartMs'] as int?, endMs: json['endMs'] as int, - attackingTeam: ReplayTeam.tryParse(json['attackingTeam']) ?? - (throw FormatException( - 'Round ${json['index']} has no attacking team.', - )), + attackingTeam: ReplayTeam.tryParse(json['attackingTeam']), winningTeam: ReplayTeam.tryParse(json['winningTeam']), endReason: ReplayRoundEnd.values .where((value) => value.name == json['endReason']) @@ -270,14 +272,16 @@ class ReplaySpikePlant { }); final int timeMs; - final ReplayVector position; + final ReplayVector? position; final String? site; final String? subject; factory ReplaySpikePlant.fromJson(Map json) => ReplaySpikePlant( timeMs: json['timeMs'] as int, - position: ReplayVector.fromJson(json['position'] as List), + position: json['position'] == null + ? null + : ReplayVector.fromJson(json['position'] as List), site: json['site'] as String?, subject: json['subject'] as String?, ); @@ -299,15 +303,14 @@ class ReplaySpikeDefuse { class ReplayEconomy { const ReplayEconomy({ required this.subject, - required this.credits, - this.loadoutValue, + this.credits, this.weapon, this.armor, }); final String subject; - final int credits; - final int? loadoutValue; + /// Null when no money was replicated by the end of the buy phase. + final int? credits; /// Equippable class path of the best weapon carried into the round. final String? weapon; @@ -317,8 +320,7 @@ class ReplayEconomy { factory ReplayEconomy.fromJson(Map json) => ReplayEconomy( subject: json['subject'] as String, - credits: json['credits'] as int, - loadoutValue: json['loadoutValue'] as int?, + credits: json['credits'] as int?, weapon: json['weapon'] as String?, armor: json['armor'] as int?, ); @@ -398,7 +400,7 @@ class ReplayUtility { spawnMs: json['spawnMs'] as int, endMs: json['endMs'] as int?, position: ReplayVector.fromJson(json['position'] as List), - yaw: (json['yaw'] as num?)?.toDouble(), + yaw: json['yaw'] == null ? null : _finite(json['yaw'] as num), path: [ for (final row in (json['path'] as List? ?? const [])) ReplayPathPoint.fromJson(row as List), @@ -428,6 +430,16 @@ class ReplayQuality { ); } +/// [value], refusing NaN and infinities: nothing non-finite may reach a +/// placed widget, and from there a saved strategy. +double _finite(num value) { + final number = value.toDouble(); + if (!number.isFinite) { + throw FormatException('Decoded replay holds a non-finite number: $value'); + } + return number; +} + /// A point in game-world centimetres. class ReplayVector { const ReplayVector(this.x, this.y, this.z); @@ -437,9 +449,9 @@ class ReplayVector { final double z; factory ReplayVector.fromJson(List json) => ReplayVector( - (json[0] as num).toDouble(), - (json[1] as num).toDouble(), - (json[2] as num).toDouble(), + _finite(json[0] as num), + _finite(json[1] as num), + _finite(json[2] as num), ); } @@ -453,9 +465,9 @@ class ReplayPathPoint { factory ReplayPathPoint.fromJson(List json) => ReplayPathPoint( (json[0] as num).toInt(), ReplayVector( - (json[1] as num).toDouble(), - (json[2] as num).toDouble(), - (json[3] as num).toDouble(), + _finite(json[1] as num), + _finite(json[2] as num), + _finite(json[3] as num), ), ); } @@ -495,10 +507,17 @@ class ReplayMovementTrack { if (offset < 0 || count < 0 || end > blob.lengthInBytes) { throw const FormatException('Movement record range is out of bounds.'); } - return ReplayMovementTrack._( - ByteData.sublistView(blob, offset, end), - count, - ); + final records = ByteData.sublistView(blob, offset, end); + for (var i = 0; i < count; i++) { + for (var field = 0; field < 5; field++) { + final value = + records.getFloat32(i * _recordBytes + 4 + field * 4, Endian.little); + if (!value.isFinite) { + throw const FormatException('Movement holds a non-finite number.'); + } + } + } + return ReplayMovementTrack._(records, count); } int timeAt(int index) => @@ -530,6 +549,14 @@ class ReplayMovementTrack { return found; } + /// The last recorded pose at or before [timeMs], if it is no older than + /// [notBeforeMs]: where a player stands while the stream is quiet. + ReplayPose? heldPose(int timeMs, {required int notBeforeMs}) { + final index = indexAtOrBefore(timeMs); + if (index < 0 || timeAt(index) < notBeforeMs) return null; + return poseAtIndex(index); + } + /// The pose at [timeMs], interpolated between the samples around it. /// Null outside the track or inside a gap longer than /// [maxInterpolationGapMs]. @@ -585,14 +612,15 @@ class ReplayVitalsTrack { for (var i = 0; i < rows.length; i++) { final row = rows[i] as List; times[i] = (row[0] as num).toInt(); - health[i] = (row[1] as num).toDouble(); - armor[i] = (row[2] as num).toDouble(); + health[i] = _finite(row[1] as num); + armor[i] = _finite(row[2] as num); } return ReplayVitalsTrack._(times, health, armor); } - /// Health and armor as of [timeMs], or null before the first row. - ({double health, double armor})? at(int timeMs) { + /// The row in force at [timeMs] (with when it was recorded), or null + /// before the first row. + ({int timeMs, double health, double armor})? at(int timeMs) { var low = 0; var high = _times.length - 1; var found = -1; @@ -607,6 +635,7 @@ class ReplayVitalsTrack { } if (found < 0) return null; return ( + timeMs: _times[found], health: math.max(0.0, _health[found]), armor: math.max(0.0, _armor[found]), ); diff --git a/lib/replay/replay_files.dart b/lib/replay/replay_files.dart index 854ae659e..676e9267e 100644 --- a/lib/replay/replay_files.dart +++ b/lib/replay/replay_files.dart @@ -27,11 +27,13 @@ class ReplayFile { final int sizeBytes; final DateTime modified; - /// Valorant names replays after their match id. - String get matchId => p.basenameWithoutExtension(path); + /// Valorant names replays after their match id. Lower-cased: Windows + /// paths don't care about case, so neither does the match id. + String get matchId => p.basenameWithoutExtension(path).toLowerCase(); - /// Identifies this exact file for caching: a replay re-downloaded or - /// replaced gets a new key. + /// Identifies this exact file for caching. Valorant never rewrites a + /// replay; one re-downloaded or replaced gets a new size or time, and so a + /// new key. String get cacheKey => '$matchId-$sizeBytes-${modified.millisecondsSinceEpoch}'; } @@ -90,7 +92,8 @@ class ReplayFiles { ).create(recursive: true); /// Every replay in both folders, newest first. A match present in both is - /// listed once, from Icarus's folder, which Valorant will not clear. + /// listed once, from Icarus's folder, which Valorant will not clear. A + /// folder that can't be read lists nothing; the other still does. Future> list() async { final byMatch = {}; final valorant = _valorantDemosPath; @@ -104,14 +107,30 @@ class ReplayFiles { ); } } - for (final file in await _scan(await icarusReplaysDirectory())) { + final Directory kept; + try { + kept = await icarusReplaysDirectory(); + } on FileSystemException { + return _newestFirst(byMatch.values); + } + for (final file in await _scan(kept)) { byMatch[file.matchId] = file; } - return byMatch.values.toList() - ..sort((a, b) => b.modified.compareTo(a.modified)); + return _newestFirst(byMatch.values); } + static List _newestFirst(Iterable files) => + files.toList()..sort((a, b) => b.modified.compareTo(a.modified)); + Future> _scan(Directory directory) async { + try { + return await _scanOrThrow(directory); + } on FileSystemException { + return const []; + } + } + + Future> _scanOrThrow(Directory directory) async { if (!await directory.exists()) return const []; final files = []; await for (final entity in directory.list(followLinks: false)) { @@ -139,9 +158,13 @@ class ReplayFiles { !await destination.exists()) { // Copy to a temporary name first so a half-copied file is never // listed as a replay. - final partial = File('${destination.path}.partial'); - await File(sourcePath).copy(partial.path); - await partial.rename(destination.path); + final partial = _partial(destination); + try { + await File(sourcePath).copy(partial.path); + await partial.rename(destination.path); + } finally { + if (await partial.exists()) await partial.delete(); + } } final stat = await destination.stat(); return ReplayFile( @@ -152,6 +175,12 @@ class ReplayFiles { ); } + /// A temporary name beside [target], unique to this write, so two writes + /// of the same file never share one. Never listed: it isn't a `.vrf`. + static File _partial(File target) => File( + '${target.path}.${DateTime.now().microsecondsSinceEpoch}.partial', + ); + Future _cacheFile(ReplayFile file, String decoderVersion) async => File( p.join( (await _cacheDirectory()).path, @@ -172,13 +201,18 @@ class ReplayFiles { Uint8List bytes, ) async { final cache = await _cacheFile(file, decoderVersion); - final partial = File('${cache.path}.partial'); - await partial.writeAsBytes(bytes, flush: true); - await partial.rename(cache.path); + final partial = _partial(cache); + try { + await partial.writeAsBytes(bytes, flush: true); + await partial.rename(cache.path); + } finally { + if (await partial.exists()) await partial.delete(); + } await for (final entity in cache.parent.list()) { final name = p.basename(entity.path); if (entity is File && name.startsWith('${file.matchId}-') && + name.endsWith('.icrp') && !p.equals(entity.path, cache.path)) { await entity.delete(); } diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index 6bf386313..a91adaa64 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -10,6 +10,7 @@ import 'package:icarus/replay/replay_ability_catalog.dart'; import 'package:icarus/replay/replay_agents.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_weapons.dart'; /// One moment of a replay as Icarus draws it: the same placed widgets a page /// holds. The viewer paints these, and capturing the moment saves them. @@ -87,39 +88,50 @@ class ReplayFrameBuilder { return byAgent; }(); - /// Whether [player] is alive at [timeMs]. Health is the truth when the - /// replay has it (it sees revives); otherwise the player is dead from their - /// death until the next round starts. + /// Whether [player] is alive at [timeMs]: whichever came last this round + /// of the round starting (alive), a health reading (alive above 0, which + /// sees revives) and a recorded death. bool isAlive(ReplayPlayer player, int timeMs, ReplayRound? round) { - final vitals = document.vitals[player.subject]?.at(timeMs); - if (vitals != null) return vitals.health > 0; final roundStart = round?.startMs ?? 0; - for (final death in _deathsBySubject[player.subject] ?? const []) { - if (death.timeMs >= roundStart && death.timeMs <= timeMs) return false; + var alive = true; + var decidedAt = roundStart; + final vitals = document.vitals[player.subject]?.at(timeMs); + if (vitals != null && vitals.timeMs >= roundStart) { + alive = vitals.health > 0; + decidedAt = vitals.timeMs; } - return true; + final death = _lastDeathBefore(player.subject, timeMs); + if (death != null && death.timeMs >= decidedAt) alive = false; + return alive; } ReplayPlayerState playerState(ReplayPlayer player, int timeMs) { final round = document.roundAt(timeMs); + final roundStart = round?.startMs ?? 0; final alive = isAlive(player, timeMs, round); final track = document.movement[player.subject]; - ReplayPose? pose = track?.poseAt(timeMs); - if (!alive && pose == null && track != null) { - // The body is gone from the stream; keep where it fell. - final death = _lastDeathBefore(player.subject, timeMs); - if (death != null) { - final index = track.indexAtOrBefore(death.timeMs); - if (index >= 0) pose = track.poseAtIndex(index); + ReplayPose? pose; + if (track != null) { + if (alive) { + // A quiet stream means standing still, not vanishing. + pose = track.poseAt(timeMs) ?? + track.heldPose(timeMs, notBeforeMs: roundStart); + } else { + // Dead this round: keep where they fell. + final death = _lastDeathBefore(player.subject, timeMs); + if (death != null && death.timeMs >= roundStart) { + pose = track.heldPose(death.timeMs, notBeforeMs: roundStart); + } } } final vitals = document.vitals[player.subject]?.at(timeMs); + final vitalsCurrent = vitals != null && vitals.timeMs >= roundStart; return ReplayPlayerState( player: player, alive: alive, pose: pose, - health: vitals?.health, - armor: vitals?.armor, + health: vitalsCurrent ? vitals.health : null, + armor: vitalsCurrent ? vitals.armor : null, ); } @@ -145,14 +157,18 @@ class ReplayFrameBuilder { final agents = []; for (final player in document.players) { final type = replayAgentType(player.agentId); - if (type == null) continue; + final team = player.team; + // Without an agent or a team there is nothing honest to draw. + if (type == null || team == null) continue; final state = playerState(player, timeMs); final pose = state.pose; if (pose == null) continue; final position = projection.toWorld(pose.position.x, pose.position.y) - agentAnchor; - final isAlly = player.team == perspective; + final isAlly = team == perspective; final id = 'replay-player-${player.subject}'; + // What they carried out of the buy phase. + final weapon = replayWeaponType(round?.economyFor(player.subject)?.weapon); agents.add( state.alive ? PlacedViewConeAgent( @@ -163,6 +179,7 @@ class ReplayFrameBuilder { rotation: projection.rotationForYaw(pose.yaw), length: _coneLength, isAlly: isAlly, + weapon: weapon, ) : PlacedAgent( id: id, @@ -170,6 +187,7 @@ class ReplayFrameBuilder { position: position, isAlly: isAlly, state: AgentState.dead, + weapon: weapon, ), ); } @@ -183,22 +201,25 @@ class ReplayFrameBuilder { final owner = ownerOf(utility, entry); // An unknown owner among duplicate agents would be a guess at the // team colour; leave it out instead. - if (owner == null) continue; + final ownerTeam = owner?.team; + if (ownerTeam == null) continue; abilities.add( entry.place( utility: utility, projection: projection, timeMs: timeMs, id: 'replay-utility-${utility.id}', - isAlly: owner.team == perspective, + isAlly: ownerTeam == perspective, ), ); } final utilities = []; final plant = round?.plant; + final plantedAt = plant?.position; if (round != null && plant != null && + plantedAt != null && timeMs >= plant.timeMs && timeMs < round.endMs) { final spike = PlacedUtility( @@ -206,7 +227,7 @@ class ReplayFrameBuilder { type: UtilityType.spike, position: Offset.zero, ); - spike.position = projection.toWorld(plant.position.x, plant.position.y) - + spike.position = projection.toWorld(plantedAt.x, plantedAt.y) - coordinates.virtualOffsetToWorld( storedUtilityAnchor( utility: spike, diff --git a/lib/replay/replay_loader.dart b/lib/replay/replay_loader.dart index 0fe0a17cc..2eb4208d5 100644 --- a/lib/replay/replay_loader.dart +++ b/lib/replay/replay_loader.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:icarus/replay/replay_decoder.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_files.dart'; @@ -14,29 +12,48 @@ class ReplayLoader { final ReplayProbe probe; ReplayDecodeJob? _job; + bool _cancelled = false; - /// 0 to 1 while decoding; 1 once read from the cache. + /// 0 to 1 while decoding. double get progress => _job?.progress ?? 0; - /// Stops a decode in progress; [load] then fails as cancelled. - void cancel() => _job?.cancel(); + /// Stops the load wherever it is; [load] then fails as cancelled. + void cancel() { + _cancelled = true; + _job?.cancel(); + } - Future load() async { - final cached = await files.readCache(file, probe.decoderVersion); - if (cached != null) { - try { - return ReplayDocument.fromBytes(cached); - } on FormatException { - // A cache from an older format: decode again below. - } + void _throwIfCancelled() { + if (_cancelled) { + throw const ReplayDecodeException('cancelled', 'Opening was cancelled.'); } + } + + Future load() async { + final cached = await _readCache(); + _throwIfCancelled(); + if (cached != null) return cached; + final job = _job = decodeReplay(file.path); - final Uint8List bytes = await job.result; + final bytes = await job.result; + _throwIfCancelled(); final document = ReplayDocument.fromBytes(bytes); - // A failed cache write only costs a decode next time. try { await files.writeCache(file, probe.decoderVersion, bytes); - } on Exception catch (_) {} + } on Exception { + // A failed cache write only costs a decode next time. + } return document; } + + /// The cached document, or null when there is none or it can't be used: + /// an unreadable or outdated cache is simply decoded again. + Future _readCache() async { + try { + final bytes = await files.readCache(file, probe.decoderVersion); + return bytes == null ? null : ReplayDocument.fromBytes(bytes); + } on Exception { + return null; + } + } } diff --git a/lib/replay/replay_playback.dart b/lib/replay/replay_playback.dart index 76a865a99..530184d62 100644 --- a/lib/replay/replay_playback.dart +++ b/lib/replay/replay_playback.dart @@ -24,6 +24,10 @@ class ReplayPlayback extends ChangeNotifier { final ReplayFrameBuilder frames; int _timeMs = 0; + + /// The part of a millisecond the clock has run but not yet shown, so + /// playback speed doesn't depend on frame rate. + double _carryMs = 0; bool _playing = false; double _speed = 1; ReplayTeam _perspective; @@ -79,6 +83,7 @@ class ReplayPlayback extends ChangeNotifier { void seek(int timeMs) { final clamped = timeMs.clamp(0, durationMs); + _carryMs = 0; if (clamped == _timeMs) return; _timeMs = clamped; notifyListeners(); @@ -87,8 +92,10 @@ class ReplayPlayback extends ChangeNotifier { /// Moves the clock by [elapsed] of wall time at the current speed. void advance(Duration elapsed) { if (!_playing) return; - final next = _timeMs + (elapsed.inMicroseconds * _speed / 1000).round(); - _timeMs = math.min(next, durationMs); + final exact = _carryMs + elapsed.inMicroseconds * _speed / 1000; + final whole = exact.floor(); + _carryMs = exact - whole; + _timeMs = math.min(_timeMs + whole, durationMs); if (_timeMs >= durationMs) _playing = false; notifyListeners(); } diff --git a/lib/replay_view.dart b/lib/replay_view.dart index 70dbc0236..ff49795ea 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -8,6 +9,8 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/replay_library_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_decoder.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_files.dart'; @@ -55,7 +58,6 @@ class _ReplayViewState extends ConsumerState ReplayPlayback? _playback; ReplayDecodeException? _error; Duration _lastTick = Duration.zero; - bool? _drawnAsAttack; @override void initState() { @@ -75,8 +77,8 @@ class _ReplayViewState extends ConsumerState } Future _open() async { - final projection = ReplayMapProjection.forMapPath(widget.probe.mapPath); try { + final projection = ReplayMapProjection.forMapPath(widget.probe.mapPath); if (projection == null) { throw const ReplayDecodeException( 'unsupportedMap', @@ -84,33 +86,46 @@ class _ReplayViewState extends ConsumerState ); } final document = await _loader.load(); + if (!mounted) return; final perspective = await _defaultPerspective(document); if (!mounted) return; final playback = ReplayPlayback( document: document, projection: projection, perspective: perspective, - )..addListener(_onPlaybackChanged); + )..addListener(_syncMapSide); setState(() => _playback = playback); - _syncMapSide(playback); + claimEditorState(); _ticker.start(); } on ReplayDecodeException catch (error) { - if (mounted) setState(() => _error = error); + if (mounted && !error.isCancelled) setState(() => _error = error); } on FormatException catch (error) { - if (mounted) { - setState( - () => _error = ReplayDecodeException('corrupt', error.message)); - } + _fail(ReplayDecodeException('corrupt', error.message)); + } catch (error) { + // Whatever else went wrong, the screen must not sit on "Reading". + _fail(ReplayDecodeException('io', '$error')); } finally { _progressTimer?.cancel(); } } + void _fail(ReplayDecodeException error) { + if (mounted) setState(() => _error = error); + } + /// The team of whoever played this match on this machine, else Red. Future _defaultPerspective(ReplayDocument document) async { - final local = await ref.read(replayFilesProvider).localSubjects(); + Set local; + try { + local = await ref.read(replayFilesProvider).localSubjects(); + } on FileSystemException { + local = const {}; + } for (final player in document.players) { - if (local.contains(player.subject.toLowerCase())) return player.team; + final team = player.team; + if (team != null && local.contains(player.subject.toLowerCase())) { + return team; + } } return ReplayTeam.red; } @@ -121,21 +136,34 @@ class _ReplayViewState extends ConsumerState _playback?.advance(delta); } - void _onPlaybackChanged() { + /// The editor's widgets this screen draws with read their map, side and + /// marker settings from the editor's providers. Point them at this replay + /// and at the settings a captured strategy starts with. Called on opening + /// and on coming back from the editor, which leaves its own state behind. + void claimEditorState() { final playback = _playback; - if (playback != null) _syncMapSide(playback); + if (playback == null) return; + final preferences = ref.read(appPreferencesProvider); + ref.read(strategySettingsProvider.notifier).fromHive( + StrategySettings( + agentSize: preferences.defaultAgentSizeForNewStrategies, + abilitySize: preferences.defaultAbilitySizeForNewStrategies, + useNeutralTeamColors: + preferences.defaultNeutralTeamColorsForNewStrategies, + ), + ); + _syncMapSide(); } - /// The editor's map widgets read the map and side from [mapProvider]; the - /// side follows the perspective team round by round. - void _syncMapSide(ReplayPlayback playback) { + /// The side follows the perspective team round by round. + void _syncMapSide() { + final playback = _playback; + if (playback == null) return; + final map = playback.frames.projection.map; final isAttack = playback.frame.isAttack; - if (_drawnAsAttack == isAttack) return; - _drawnAsAttack = isAttack; - ref.read(mapProvider.notifier).fromHive( - ReplayMapProjection.forMapPath(widget.probe.mapPath)!.map, - isAttack, - ); + final current = ref.read(mapProvider); + if (current.currentMap == map && current.isAttack == isAttack) return; + ref.read(mapProvider.notifier).fromHive(map, isAttack); } @override @@ -144,7 +172,7 @@ class _ReplayViewState extends ConsumerState _loader.cancel(); _ticker.dispose(); _playback - ?..removeListener(_onPlaybackChanged) + ?..removeListener(_syncMapSide) ..dispose(); super.dispose(); } @@ -183,7 +211,11 @@ class _ReplayViewState extends ConsumerState _ReplayStrip(map: map, probe: widget.probe), Expanded( child: playback != null - ? _ReplayBench(playback: playback, map: map!) + ? _ReplayBench( + playback: playback, + map: map!, + onReturnFromEditor: claimEditorState, + ) : _ReplayLoading( progress: _loader.progress, error: _error, @@ -259,10 +291,15 @@ String replayMapName(MapValue map) { /// match and actions top-left, roster on the right, timeline along the /// bottom. class _ReplayBench extends StatelessWidget { - const _ReplayBench({required this.playback, required this.map}); + const _ReplayBench({ + required this.playback, + required this.map, + required this.onReturnFromEditor, + }); final ReplayPlayback playback; final MapValue map; + final VoidCallback onReturnFromEditor; @override Widget build(BuildContext context) { @@ -281,7 +318,11 @@ class _ReplayBench extends StatelessWidget { alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.all(8), - child: ReplayMatchCard(playback: playback, map: map), + child: ReplayMatchCard( + playback: playback, + map: map, + onReturnFromEditor: onReturnFromEditor, + ), ), ), Positioned( diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 557d21a17..87f0c2718 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -35,6 +35,7 @@ class StrategyView extends ConsumerStatefulWidget { this.initialStrategyName, this.initialMapValue, this.initialIsAttack = true, + this.initialPageId, }); final String? initialStrategyId; @@ -42,11 +43,15 @@ class StrategyView extends ConsumerStatefulWidget { final MapValue? initialMapValue; final bool initialIsAttack; + /// The page to open on; the first when null. + final String? initialPageId; + static PageRoute route({ String? initialStrategyId, String? initialStrategyName, MapValue? initialMapValue, bool initialIsAttack = true, + String? initialPageId, }) { return PageRouteBuilder( settings: const RouteSettings(name: Routes.strategyView), @@ -57,6 +62,7 @@ class StrategyView extends ConsumerStatefulWidget { initialStrategyName: initialStrategyName, initialMapValue: initialMapValue, initialIsAttack: initialIsAttack, + initialPageId: initialPageId, ), transitionsBuilder: (context, animation, _, child) { return FadeTransition( @@ -121,7 +127,9 @@ class _StrategyViewState extends ConsumerState } try { - await ref.read(strategyProvider.notifier).loadFromHive(strategyId); + await ref + .read(strategyProvider.notifier) + .loadFromHive(strategyId, pageId: widget.initialPageId); final loadedStrategy = ref.read(strategyProvider); if (loadedStrategy.id != strategyId || loadedStrategy.stratName == null) { throw StateError('Strategy "$strategyId" was not found.'); diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 25be9f884..674e92e18 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -78,7 +78,7 @@ class _ReplayCanvasState extends ConsumerState { gradient: RadialGradient( radius: 1.5, colors: [ - const Color(0xff18181b), + ShadTheme.of(context).colorScheme.card, ShadTheme.of(context).colorScheme.background, ], ), diff --git a/lib/widgets/replay/replay_dock.dart b/lib/widgets/replay/replay_dock.dart index 9ba0e680f..16478f4a4 100644 --- a/lib/widgets/replay/replay_dock.dart +++ b/lib/widgets/replay/replay_dock.dart @@ -265,7 +265,7 @@ class _ScrubberPainter extends CustomPainter { final victim = playback.document.playerBySubject(kill.victim); // A kill counts for the killer's team; a fall or spike death for // the other team. - final scoringTeam = killer?.team ?? victim?.team.other; + final scoringTeam = killer?.team ?? victim?.team?.other; final color = scoringTeam == null ? theme.mutedForeground : scoringTeam == playback.perspective diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index a649a2a4b..64da5ccb6 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/replay/replay_capture.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_playback.dart'; @@ -13,24 +14,34 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// Where the match stands, and the one thing to do with it: capture the /// moment into a strategy. class ReplayMatchCard extends ConsumerStatefulWidget { - const ReplayMatchCard({super.key, required this.playback, required this.map}); + const ReplayMatchCard({ + super.key, + required this.playback, + required this.map, + required this.onReturnFromEditor, + }); final ReplayPlayback playback; final MapValue map; + /// Called after the captured strategy's editor closes, which leaves its + /// own map and settings behind. + final VoidCallback onReturnFromEditor; + @override ConsumerState createState() => _ReplayMatchCardState(); } class _ReplayMatchCardState extends ConsumerState { late final ReplayCapture _capture = ReplayCapture( - ref: ref, + createStrategy: (map, name) => ref + .read(strategyProvider.notifier) + .createNewStrategy(map: map, name: name), map: widget.map, name: '${replayMapName(widget.map)} replay · ' '${_dateLabel(widget.playback.document.match.recordedAt)}', ); bool _capturing = false; - int _captured = 0; String _dateLabel(DateTime? date) => MaterialLocalizations.of(context).formatShortDate(date ?? DateTime.now()); @@ -48,7 +59,6 @@ class _ReplayMatchCardState extends ConsumerState { perspective: playback.perspective, ); await _capture.capture(frame, pageName: replayMomentLabel(playback)); - setState(() => _captured++); } catch (error, stackTrace) { AppErrorReporter.reportError( "Couldn't capture this moment.", @@ -61,19 +71,28 @@ class _ReplayMatchCardState extends ConsumerState { } } - void _openStrategy() { + /// Opens the strategy on the page just captured. + Future _openStrategy() async { final strategy = _capture.strategy; if (strategy == null) return; widget.playback.pause(); - Navigator.push( + final page = strategy.pages.firstWhere( + (page) => page.id == _capture.lastPageId, + orElse: () => strategy.pages.last, + ); + await Navigator.push( context, StrategyView.route( initialStrategyId: strategy.id, initialStrategyName: strategy.name, initialMapValue: strategy.mapData, - initialIsAttack: strategy.pages.last.isAttack, + initialIsAttack: page.isAttack, + initialPageId: page.id, ), ); + if (!mounted) return; + widget.onReturnFromEditor(); + setState(() {}); } @override @@ -94,6 +113,7 @@ class _ReplayMatchCardState extends ConsumerState { final round = playback.round; final score = _score(playback); final isAttack = playback.frame.isAttack; + final captured = _capture.strategy?.pages.length ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, @@ -143,15 +163,15 @@ class _ReplayMatchCardState extends ConsumerState { ), ], ), - if (_captured > 0) ...[ + if (captured > 0) ...[ const SizedBox(height: 8), Row( children: [ Expanded( child: Text( - _captured == 1 + captured == 1 ? '1 page captured' - : '$_captured pages captured', + : '$captured pages captured', style: theme.textTheme.muted, ), ), @@ -171,13 +191,12 @@ class _ReplayMatchCardState extends ConsumerState { ); } - /// Rounds won before the one being watched. + /// Rounds decided by the moment being watched. ({int ally, int enemy}) _score(ReplayPlayback playback) { - final current = playback.round?.index ?? 0; var ally = 0; var enemy = 0; for (final round in playback.document.rounds) { - if (round.index >= current) break; + if (round.endMs > playback.timeMs) break; final winner = round.winningTeam; if (winner == null) continue; if (winner == playback.perspective) { diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart index 9f8bd0739..d206e0d3c 100644 --- a/lib/widgets/replay/replay_roster.dart +++ b/lib/widgets/replay/replay_roster.dart @@ -120,6 +120,7 @@ class _PlayerRow extends StatelessWidget { final agentData = agent == null ? null : AgentData.agents[agent]; final weapon = replayWeaponType(economy?.weapon); final name = state.player.name ?? agentData?.name ?? 'Unknown agent'; + final credits = economy?.credits; return Opacity( opacity: state.alive ? 1 : 0.4, child: SizedBox( @@ -145,7 +146,7 @@ class _PlayerRow extends StatelessWidget { name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13), + style: const TextStyle(fontSize: 14), ), const SizedBox(height: 4), _VitalsBar(health: state.health, armor: state.armor), @@ -153,6 +154,8 @@ class _PlayerRow extends StatelessWidget { ), ), const SizedBox(width: 8), + // What they carried out of the buy phase, and what they had + // left to spend. Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, @@ -161,10 +164,25 @@ class _PlayerRow extends StatelessWidget { Image.asset(weapon.iconPath, height: 14) else const SizedBox(height: 14), - if (economy != null) - Text( - '${economy!.credits}', - style: theme.textTheme.muted.copyWith(fontSize: 11), + if (credits != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.coins, + size: 10, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 3), + Text( + '$credits', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.mutedForeground, + ), + ), + ], ), ], ), @@ -175,7 +193,8 @@ class _PlayerRow extends StatelessWidget { } } -/// Health as a filled bar out of 100, armor as a thinner bar under it. +/// Health as a filled bar out of 100, armor as a thinner bar under it. An +/// unknown reading shows the empty track, never a guess. class _VitalsBar extends StatelessWidget { const _VitalsBar({required this.health, required this.armor}); @@ -205,7 +224,7 @@ class _VitalsBar extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - bar((health ?? 100) / 100, Settings.tacticalVioletTheme.foreground, 3), + bar((health ?? 0) / 100, Settings.tacticalVioletTheme.foreground, 3), const SizedBox(height: 2), bar((armor ?? 0) / 50, Settings.tacticalVioletTheme.mutedForeground, 2), ], @@ -304,9 +323,8 @@ class _KillIcon extends StatelessWidget { color: Settings.tacticalVioletTheme.secondary, borderRadius: BorderRadius.circular(4), border: Border.all( - color: player == null - ? Settings.tacticalVioletTheme.border - : player!.team.colorFor(perspective), + color: player?.team?.colorFor(perspective) ?? + Settings.tacticalVioletTheme.border, ), ), child: diff --git a/test/replay_capture_test.dart b/test/replay_capture_test.dart new file mode 100644 index 000000000..d954213ba --- /dev/null +++ b/test/replay_capture_test.dart @@ -0,0 +1,141 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/replay/replay_capture.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:uuid/uuid.dart'; + +/// What the library's New Strategy makes: one blank page. +Future createBlankStrategy(MapValue map, String name) async { + final strategy = StrategyData( + id: const Uuid().v4(), + name: name, + mapData: map, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + folderID: null, + pages: [ + StrategyPage( + id: const Uuid().v4(), + name: 'Page 1', + isAutoNamed: true, + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ), + ], + ); + await Hive.box(HiveBoxNames.strategiesBox) + .put(strategy.id, strategy); + return strategy; +} + +ReplayFrame frame({Offset position = const Offset(500, 500)}) => ReplayFrame( + timeMs: 1000, + round: null, + isAttack: false, + agents: [ + PlacedViewConeAgent( + id: 'replay-player-a', + type: AgentType.jett, + position: position, + presetType: UtilityType.viewCone180, + rotation: 1, + length: 100, + ), + ], + abilities: const [], + utilities: const [], + ); + +void main() { + late Directory temp; + late Box box; + + setUpAll(() async { + temp = await Directory.systemTemp.createTemp('icarus-replay-capture'); + Hive.init(temp.path); + registerIcarusAdapters(Hive); + }); + + setUp(() async { + box = await Hive.openBox(HiveBoxNames.strategiesBox); + await box.clear(); + }); + + tearDownAll(() async { + await Hive.close(); + await temp.delete(recursive: true); + }); + + ReplayCapture capture() => ReplayCapture( + createStrategy: createBlankStrategy, + map: MapValue.sunset, + name: 'Sunset replay', + ); + + test('captures fill one strategy, the first replacing its blank page', + () async { + final session = capture(); + await session.capture(frame(), pageName: 'Round 1 · 0:10'); + await session.capture(frame(), pageName: 'Round 1 · 0:20'); + + final strategy = box.values.single; + expect(strategy.name, 'Sunset replay'); + expect(strategy.mapData, MapValue.sunset); + expect(strategy.pages.map((page) => page.name), + ['Round 1 · 0:10', 'Round 1 · 0:20']); + expect(strategy.pages.map((page) => page.sortIndex), [0, 1]); + expect(strategy.pages.last.id, session.lastPageId); + final page = strategy.pages.first; + expect(page.isAttack, isFalse); + final agent = page.agentData.single as PlacedViewConeAgent; + expect(agent.id, 'replay-player-a'); + expect(agent.position, const Offset(500, 500)); + }); + + test('a deleted or re-mapped strategy is never written to again', () async { + final session = capture(); + final first = await session.capture(frame(), pageName: 'one'); + await box.delete(first.id); + + final second = await session.capture(frame(), pageName: 'two'); + expect(second.id, isNot(first.id)); + expect(second.pages.single.name, 'two'); + + await box.put(second.id, second.copyWith(mapData: MapValue.ascent)); + final third = await session.capture(frame(), pageName: 'three'); + expect(third.id, isNot(second.id)); + expect(box.get(second.id)!.pages.single.name, 'two'); + }); + + test('a frame off the map is refused before anything is written', () async { + final session = capture(); + await expectLater( + session.capture( + frame(position: const Offset(double.nan, 0)), + pageName: 'bad', + ), + throwsStateError, + ); + expect(box.isEmpty, isTrue); + }); +} diff --git a/test/replay_document_test.dart b/test/replay_document_test.dart index 8a8eafc9c..e584c6d1d 100644 --- a/test/replay_document_test.dart +++ b/test/replay_document_test.dart @@ -4,88 +4,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/replay/replay_document.dart'; -/// Builds an `.icrp` buffer the way `native/replay` lays it out. -Uint8List icrp(Map json, - {List> records = const []}) { - final jsonBytes = utf8.encode(jsonEncode(json)); - final blob = ByteData(records.length * 24); - for (var i = 0; i < records.length; i++) { - final r = records[i]; - blob.setInt32(i * 24, r[0].toInt(), Endian.little); - for (var f = 0; f < 5; f++) { - blob.setFloat32(i * 24 + 4 + f * 4, r[f + 1].toDouble(), Endian.little); - } - } - final blobStart = (12 + jsonBytes.length + 7) & ~7; - final bytes = Uint8List(blobStart + blob.lengthInBytes); - final header = ByteData.sublistView(bytes); - bytes.setAll(0, ascii.encode('ICRP')); - header.setUint32(4, ReplayDocument.formatVersion, Endian.little); - header.setUint32(8, jsonBytes.length, Endian.little); - bytes.setAll(12, jsonBytes); - bytes.setAll(blobStart, blob.buffer.asUint8List()); - return bytes; -} - -Map sampleJson({Map? movement}) => { - 'decoder': {'name': 'icarus_replay', 'version': 'test'}, - 'match': { - 'id': 'match', - 'mapPath': '/Game/Maps/Juliett/Juliett', - 'build': '++Ares-Core+release-13.00', - 'durationMs': 100000, - 'recordedAt': '2026-07-10T03:35:45Z', - }, - 'players': [ - { - 'subject': 'a', - 'agentId': 'ADD6443A-41BD-E414-F6AD-E58D267F4E95', - 'team': 'Red' - }, - { - 'subject': 'b', - 'agentId': '8e253930-4c05-31dd-1b6c-968525494517', - 'team': 'Blue' - }, - ], - 'rounds': [ - { - 'index': 0, - 'startMs': 1000, - 'combatStartMs': 31000, - 'endMs': 60000, - 'attackingTeam': 'Red', - 'winningTeam': 'Blue', - 'endReason': 'defused', - 'economy': [] - }, - { - 'index': 1, - 'startMs': 60000, - 'endMs': 100000, - 'attackingTeam': 'Red', - 'economy': [ - {'subject': 'a', 'credits': 900, 'loadoutValue': 3900, 'armor': 50}, - ] - }, - ], - 'kills': [ - {'timeMs': 50000, 'victim': 'a', 'killer': 'b', 'assists': []}, - {'timeMs': 40000, 'victim': 'b', 'killer': 'a'}, - ], - 'vitals': { - 'a': [ - [1000, 100, 0], - [45000, 40, 0], - [50000, 0, 0], - [60000, 100, 50] - ], - }, - 'utility': [], - 'casts': [], - 'movement': movement ?? {}, - 'quality': {'transformVerified': true, 'decodeErrors': 0}, - }; +import 'replay_test_support.dart'; void main() { test('reads the container, players, rounds and kills', () { diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart new file mode 100644 index 000000000..c496a380e --- /dev/null +++ b/test/replay_frame_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_playback.dart'; + +import 'replay_test_support.dart'; + +const jett = 'add6443a-41bd-e414-f6ad-e58d267f4e95'; +const omen = '8e253930-4c05-31dd-1b6c-968525494517'; +const omenSmoke = + '/Game/Characters/Wraith/S0/Ability_4/Zone_Wraith_4_Smoke.Zone_Wraith_4_Smoke_C'; + +/// One round 0–60 s, a second from 60 s; Red attacks both. +ReplayDocument document({ + List>? players, + List> kills = const [], + Map vitals = const {}, + List> utility = const [], + Map>> movement = const {}, +}) { + final records = >[]; + final movementJson = {}; + for (final entry in movement.entries) { + movementJson[entry.key] = { + 'offset': records.length * 24, + 'count': entry.value.length, + }; + records.addAll(entry.value); + } + return ReplayDocument.fromBytes(icrp( + { + ...sampleJson(), + 'players': players ?? + [ + {'subject': 'a', 'agentId': jett, 'team': 'Red'}, + {'subject': 'b', 'agentId': omen, 'team': 'Blue'}, + ], + 'rounds': [ + {'index': 0, 'startMs': 0, 'endMs': 60000, 'attackingTeam': 'Red'}, + {'index': 1, 'startMs': 60000, 'endMs': 120000, 'attackingTeam': 'Red'}, + ], + 'kills': kills, + 'vitals': vitals, + 'utility': utility, + 'movement': movementJson, + }, + records: records, + )); +} + +/// Standing still at (x, 0) from [from] to [to], one sample a second. +List> still(int from, int to, {double x = 0}) => [ + for (var t = from; t <= to; t += 1000) [t, x, 0, 100, 90, 0], + ]; + +void main() { + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1778, 1000))); + + final projection = ReplayMapProjection.forMapPath( + '/Game/Maps/Juliett/Juliett', + )!; + + ReplayFrameBuilder builder(ReplayDocument document) => + ReplayFrameBuilder(document, projection); + + ReplayPlayer player(ReplayDocument document, String subject) => + document.playerBySubject(subject)!; + + group('alive', () { + test('a death after a health reading wins', () { + final doc = document( + kills: [ + {'timeMs': 20000, 'victim': 'a', 'killer': 'b'}, + ], + vitals: { + 'a': [ + [0, 100, 0], + [15000, 40, 0], + ], + }, + ); + final frames = builder(doc); + final a = player(doc, 'a'); + expect(frames.isAlive(a, 19999, doc.roundAt(19999)), isTrue); + expect(frames.isAlive(a, 25000, doc.roundAt(25000)), isFalse); + }); + + test('a revive after the death brings them back', () { + final doc = document( + kills: [ + {'timeMs': 20000, 'victim': 'a', 'killer': 'b'}, + ], + vitals: { + 'a': [ + [0, 100, 0], + [20000, 0, 0], + [30000, 30, 0], + ], + }, + ); + final frames = builder(doc); + final a = player(doc, 'a'); + expect(frames.isAlive(a, 25000, doc.roundAt(25000)), isFalse); + expect(frames.isAlive(a, 31000, doc.roundAt(31000)), isTrue); + }); + + test('a new round revives everyone, even with no fresh reading', () { + final doc = document( + kills: [ + {'timeMs': 20000, 'victim': 'a', 'killer': 'b'}, + ], + vitals: { + 'a': [ + [20000, 0, 0], + ], + }, + ); + final frames = builder(doc); + expect(frames.isAlive(player(doc, 'a'), 61000, doc.roundAt(61000)), + isTrue); + // And last round's reading is not shown as this round's health. + expect(frames.playerState(player(doc, 'a'), 61000).health, isNull); + }); + }); + + group('frames', () { + test('a quiet stream keeps a living player where they stood', () { + final doc = document(movement: { + 'a': [ + [1000, 500, 0, 100, 90, 0], + ], + }); + final frame = builder(doc).frameAt(30000, perspective: ReplayTeam.red); + expect(frame.agents.single, isA()); + }); + + test('a dead player stays where they fell', () { + final doc = document( + kills: [ + {'timeMs': 5000, 'victim': 'a', 'killer': 'b'}, + ], + movement: { + // The stream keeps moving after the death (the spectator camera). + 'a': [...still(0, 5000, x: 100), ...still(6000, 9000, x: 3000)], + }, + ); + final frames = builder(doc); + final atDeath = frames.playerState(player(doc, 'a'), 5000).pose!; + final later = frames.playerState(player(doc, 'a'), 9000); + expect(later.alive, isFalse); + expect(later.pose!.position.x, atDeath.position.x); + final agent = frames + .frameAt(9000, perspective: ReplayTeam.red) + .agents + .single as PlacedAgent; + expect(agent.state, AgentState.dead); + }); + + test('players with no team or unknown agent are not drawn', () { + final doc = document( + players: [ + {'subject': 'a', 'agentId': jett}, + {'subject': 'b', 'agentId': 'not-an-agent', 'team': 'Blue'}, + ], + movement: {'a': still(0, 2000), 'b': still(0, 2000)}, + ); + expect( + builder(doc).frameAt(1000, perspective: ReplayTeam.red).agents, + isEmpty, + ); + }); + + test('ally and side follow the perspective', () { + final doc = document(movement: {'a': still(0, 2000)}); + final frames = builder(doc); + final red = frames.frameAt(1000, perspective: ReplayTeam.red); + final blue = frames.frameAt(1000, perspective: ReplayTeam.blue); + expect(red.isAttack, isTrue); + expect(blue.isAttack, isFalse); + expect(red.agents.single.isAlly, isTrue); + expect(blue.agents.single.isAlly, isFalse); + }); + + test('ownerless utility is drawn only when one player could own it', () { + final smoke = { + 'id': 1, + 'classPath': omenSmoke, + 'spawnMs': 1000, + 'endMs': 9000, + 'position': [0, 0, 100], + }; + final sole = document(utility: [smoke]); + final ability = builder(sole) + .frameAt(2000, perspective: ReplayTeam.red) + .abilities + .single; + expect(ability.isAlly, isFalse); // Omen is Blue. + + final twoOmens = document( + players: [ + {'subject': 'a', 'agentId': omen, 'team': 'Red'}, + {'subject': 'b', 'agentId': omen, 'team': 'Blue'}, + ], + utility: [smoke], + ); + expect( + builder(twoOmens) + .frameAt(2000, perspective: ReplayTeam.red) + .abilities, + isEmpty, + ); + }); + }); + + group('playback', () { + ReplayPlayback playback() => ReplayPlayback( + document: document(), + projection: projection, + perspective: ReplayTeam.red, + ); + + test('speed is exact whatever the frame rate', () { + final half = playback() + ..speed = 0.5 + ..play(); + final start = half.timeMs; + for (var i = 0; i < 60; i++) { + half.advance(const Duration(microseconds: 16667)); + } + expect(half.timeMs - start, inInclusiveRange(499, 501)); + }); + + test('previous round goes to this round first, then the one before', () { + final p = playback()..seek(70000); + p.previousRound(); + expect(p.timeMs, 60000); + p.previousRound(); + expect(p.timeMs, 0); + }); + + test('playing stops at the end', () { + final p = playback(); + p + ..seek(p.durationMs - 10) + ..play(); + p.advance(const Duration(seconds: 1)); + expect(p.timeMs, p.durationMs); + expect(p.playing, isFalse); + }); + }); +} diff --git a/test/replay_test_support.dart b/test/replay_test_support.dart new file mode 100644 index 000000000..0fbd13938 --- /dev/null +++ b/test/replay_test_support.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:icarus/replay/replay_document.dart'; + +/// Builds an `.icrp` buffer the way `native/replay` lays it out. +Uint8List icrp(Map json, + {List> records = const []}) { + final jsonBytes = utf8.encode(jsonEncode(json)); + final blob = ByteData(records.length * 24); + for (var i = 0; i < records.length; i++) { + final r = records[i]; + blob.setInt32(i * 24, r[0].toInt(), Endian.little); + for (var f = 0; f < 5; f++) { + blob.setFloat32(i * 24 + 4 + f * 4, r[f + 1].toDouble(), Endian.little); + } + } + final blobStart = (12 + jsonBytes.length + 7) & ~7; + final bytes = Uint8List(blobStart + blob.lengthInBytes); + final header = ByteData.sublistView(bytes); + bytes.setAll(0, ascii.encode('ICRP')); + header.setUint32(4, ReplayDocument.formatVersion, Endian.little); + header.setUint32(8, jsonBytes.length, Endian.little); + bytes.setAll(12, jsonBytes); + bytes.setAll(blobStart, blob.buffer.asUint8List()); + return bytes; +} + +Map sampleJson({Map? movement}) => { + 'decoder': {'name': 'icarus_replay', 'version': 'test'}, + 'match': { + 'id': 'match', + 'mapPath': '/Game/Maps/Juliett/Juliett', + 'build': '++Ares-Core+release-13.00', + 'durationMs': 100000, + 'recordedAt': '2026-07-10T03:35:45Z', + }, + 'players': [ + { + 'subject': 'a', + 'agentId': 'ADD6443A-41BD-E414-F6AD-E58D267F4E95', + 'team': 'Red' + }, + { + 'subject': 'b', + 'agentId': '8e253930-4c05-31dd-1b6c-968525494517', + 'team': 'Blue' + }, + ], + 'rounds': [ + { + 'index': 0, + 'startMs': 1000, + 'combatStartMs': 31000, + 'endMs': 60000, + 'attackingTeam': 'Red', + 'winningTeam': 'Blue', + 'endReason': 'defused', + 'economy': [] + }, + { + 'index': 1, + 'startMs': 60000, + 'endMs': 100000, + 'attackingTeam': 'Red', + 'economy': [ + {'subject': 'a', 'credits': 900, 'loadoutValue': 3900, 'armor': 50}, + ] + }, + ], + 'kills': [ + {'timeMs': 50000, 'victim': 'a', 'killer': 'b', 'assists': []}, + {'timeMs': 40000, 'victim': 'b', 'killer': 'a'}, + ], + 'vitals': { + 'a': [ + [1000, 100, 0], + [45000, 40, 0], + [50000, 0, 0], + [60000, 100, 50] + ], + }, + 'utility': [], + 'casts': [], + 'movement': movement ?? {}, + 'quality': {'transformVerified': true, 'decodeErrors': 0}, + }; + From ff8c1f687fe2c9626af22b98cda3386e7ceebc4f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 01:06:06 -0400 Subject: [PATCH 05/18] Finish the replay decoder: vitals, utility shapes, release wiring Decodes all 7 local replays with no mismatch against the Event chunks and the vrfkit oracle. Vitals fold round resets, damage, heals, deaths, revives and armor items into a row per change; utility carries paths for moving pawns and projectiles, Trapwire far ends, and wall points for Fast Lane, Shear, Barrier Mesh and Blaze. Release and Store builds install icarus_replay.dll beside the app and the updater archive refuses to ship without it; CI runs the crate's tests and clippy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 4 +- .github/workflows/release-desktop.yml | 7 + .github/workflows/release-store.yml | 7 + docs/replay-format.md | 91 +++- native/replay/build_macos.sh | 7 +- native/replay/src/analysis/casts.rs | 22 +- native/replay/src/analysis/mod.rs | 36 +- native/replay/src/analysis/movement.rs | 102 ++--- native/replay/src/analysis/rounds.rs | 33 +- native/replay/src/analysis/utility.rs | 194 +++++++- native/replay/src/analysis/vitals.rs | 477 ++++++++++++++------ native/replay/src/bin/icarus-replay-dump.rs | 15 +- native/replay/src/collect.rs | 30 +- native/replay/src/decode.rs | 9 - native/replay/src/document.rs | 47 +- native/replay/tests/corpus.rs | 289 +++++++++--- scripts/build_desktop_release.ps1 | 5 + 17 files changed, 1033 insertions(+), 342 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f9d94572..f5b2ca66b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,9 @@ jobs: cargo clippy --locked --all-targets --features dev-vectors -- -D warnings - name: Test - run: cargo test --locked + run: | + cargo test --locked + cargo test --locked --features dev-vectors validate: runs-on: windows-latest diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index fd09db3cd..ce010dcc9 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -82,6 +82,13 @@ jobs: shell: pwsh run: fvm install + # windows/CMakeLists.txt builds the replay decoder (native/replay) with cargo. + - name: Install Rust + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - name: Build Windows Release Binaries shell: pwsh env: diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 3c142c1a5..29f0d803e 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -51,6 +51,13 @@ jobs: shell: pwsh run: powershell -ExecutionPolicy Bypass -File scripts/bump_version.ps1 -Bump ${{ inputs.version_bump }} + # windows/CMakeLists.txt builds the replay decoder (native/replay) with cargo. + - name: Install Rust + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - name: Build Store Package shell: pwsh env: diff --git a/docs/replay-format.md b/docs/replay-format.md index e549088a7..fa7aab316 100644 --- a/docs/replay-format.md +++ b/docs/replay-format.md @@ -1,7 +1,8 @@ # Decoded replay format `native/replay` turns a Valorant `.vrf` into one decoded replay: a byte -buffer that Icarus caches on disk as `.icrp` and reads in Dart. +buffer that Icarus caches on disk (one `.icrp` per replay file and decoder +version) and reads in Dart. It holds Valorant facts only. Nothing in it names an Icarus agent, ability, page, or widget; `lib/replay/` maps the facts onto Icarus. @@ -54,7 +55,6 @@ first byte. "subject": "978b3946-…", "agentId": "1dbf2edd-…", // valorant-api.com agent UUID; null if the header lacks it "team": "Red", // null if no combat report names it - "name?": "…", // never set yet: the replay carries no display name "characterGuids": [1206, 45530] // every body this player had, in order } ], @@ -74,7 +74,6 @@ first byte. { "subject": "…", "credits": 3900, // null if no Money was replicated by then - "loadoutValue?": 4700, // never set yet: not replicated per player (13.00) "weapon?": "…", // primary, else sidearm, equippable class path "armor?": 50 // shield points of the armour worn: 25 or 50 } @@ -91,7 +90,7 @@ first byte. } ], "vitals": { - "": [[timeMs, health, armor], …] // one row per change; null until the wire states it + "": [[timeMs, health, armor], …] // see "Vitals" }, "utility": [ { @@ -101,16 +100,16 @@ first byte. "spawnMs": 61000, "endMs?": 76000, // actor closed "position": [x, y, z], - "yaw?": 90.0, - "path?": [[timeMs, x, y, z], …], // later replicated positions (projectiles, moving walls) - "points?": [[x, y, z], …] // shape points (walls, paths drawn in-game) + "yaw?": 90.0, // absent only for an actor with no spawn transform + "path?": [[timeMs, x, y, z], …], // where it went, about 10 Hz; see "Utility" + "points?": [[x, y, z], …] // shape points; see "Utility" } ], "casts": [ { "timeMs": 60500, "subject": "…", - "slot": 4, // the replicated ability slot + "slot": 4, // 3 grenade (C), 4 ability one (Q), 5 ability two (E), 9 ultimate (X) "classPath?": "…", // the ability item; absent where the wire does not tie it "position?": [x, y, z] } @@ -121,7 +120,7 @@ first byte. "quality": { "transformVerified": true, "decodeErrors": 0, - "warnings": ["…"] + "warnings": ["…"] // see "Warnings" } } ``` @@ -140,9 +139,77 @@ f32 yaw f32 pitch ``` -Records come from every body in the player's `characterGuids`. A body that -is parked off-map between lives (around x -50000, z -49900) is dropped, not -recorded. `yaw` is 0..360 as replicated; `pitch` is signed, -180..180. +Records come from every body in the player's `characterGuids`, wherever it +is. Iso's Kill Contract duels its two players in an arena off the map, around +x ±50000, z -49900, so their records sit there for the duel (6-7 s in our +corpus). `yaw` is 0..360 as replicated; `pitch` is signed, -180..180. + +## Vitals + +Each player's rows start at the first round's start; nothing is known +before it. Every round opens with a row for every player at health 100, the +reset the game sends each body (the first round's bodies spawn full). After +that there is one row per change: damage and heals set health to the wire's +absolute result, a death leaves it at 0, and a revive (Sage, Clove) is a +reset back to 100. Heals and regenerating shields report every tick, about +125 a second, so a healed player has hundreds of rows. + +Armour is the armour item the player wears: bought, it counts its full +points (25 or 50) until it reports a lower value, a survivor carries it into +the next round, and selling it or a round's end after death leaves 0. +Values are rounded to two decimals; both are always numbers. + +## Utility + +`yaw` is the actor's spawn yaw. A spawn sends its rotation only when it is +not zero, so a spawned actor without one has yaw 0; `yaw` is absent only +for an actor that opened with no spawn transform at all. + +`path` is where the actor went while open, from its `ReplicatedMovement` +(projectiles, moving smokes and walls) and, for pawns (Boom Bot, Owl Drone, +Prowler, Wingman, Seekers, Trailblazer, Yoru's decoy and fake teleporter, +Cypher's camera, Killjoy's bots), from the movement records the game sends +for every pawn. Repeats are dropped and points kept at least 100 ms apart, +with the last always kept. It is absent when the actor never left its +`position`. + +`points` is a shape, each family's own replicated geometry, in wire order: + +| Utility | `points` | Source | +|---|---|---| +| Phoenix's Blaze (`GameObject_Phoenix_Q_FlameWallManager_Production`), Viper's Toxic Screen (`GameObject_Pandemic_E_SmokeScreenManager`, not in our corpus) | the wall, start to end | `MulticastAddSmokeScreenPoint.Translation` | +| Neon's Fast Lane (`GameObject_Sprinter_4_Tunnel`) | the lane, start to end, 125 cm apart | `MulticastAddTunnelPoint.Translation` | +| Vyse's Shear (`GameObject_Nox_Wall`) | the wall's start and end | `MulticastInitializeWall.WallStartLocation`, `WallEndLocation` | +| Vyse's Shear trap (`GameObject_Nox_WallTrap`) | the wall it raises, start and end | `MulticastInitializeTrapAnchors.WallStartPoint`, `WallEndPoint` | +| Deadlock's Barrier Mesh (`GameObject_CableJamRoot`) | each arm's end, one per arm that deployed (a blocked arm has none) | each `GameObject_CableJam_CableDeployer_Precomputed` it owns: `MulticastInitialize.Destination` | +| Cypher's Trapwire (`GameObject_Gumshoe_E_TripWire`) | one point, the far anchor; the actor is the near one | the `GameObject_Gumshoe_E_TripWire_SecondWire` opened with it, same millisecond and `Owner` | + +Every other utility has none. The Fast Lane, Shear and Barrier Mesh values +are vectors vrfkit leaves untyped; the decoder reads them as three +little-endian doubles, which put each wall's first point on its actor and +each mesh's `RootLocation` on its root. + +## Players + +The replay names players by `subject` alone. It carries no display name or +Riot ID: the PlayerState's `ProfileName` is the crosshair profile ("Imported +Crosshair 07-15-2023 14:55:05"). + +## Casts + +`slot` names the ability: 3, 4, 5 and 9 are valorant-api.com's `Grenade`, +`Ability1`, `Ability2` and `Ultimate` of the caster's agent. Every cast of 26 +agents across our 7 replays agrees with the item `classPath` where one is +known. `classPath` is the ability item, absent where no charge spend ties a +cast to it (always for ultimates). + +## Warnings + +`quality.warnings` names what the decoder dropped, could not attribute, or +chose between, one line each: decode failures by kind, deaths or utility it +could not attribute, assists it could not separate, rounds the replay stops +inside, and game phases sent twice (where `combatStartMs` takes the first). +A clean replay has none. ## Probe diff --git a/native/replay/build_macos.sh b/native/replay/build_macos.sh index 7fd5e359c..c6fa497b5 100644 --- a/native/replay/build_macos.sh +++ b/native/replay/build_macos.sh @@ -7,9 +7,14 @@ set -eu source_dir="$SRCROOT/../native/replay" output_dir="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH" target_dir="$TARGET_TEMP_DIR/icarus_replay_cargo" +# Xcode's PATH does not include rustup's bin directory. cargo="${CARGO:-$HOME/.cargo/bin/cargo}" if [ ! -x "$cargo" ]; then - cargo="$(command -v cargo)" + cargo="$(command -v cargo || true)" +fi +if [ -z "$cargo" ]; then + echo "error: cargo not found; install Rust with rustup to build the replay decoder" >&2 + exit 1 fi mkdir -p "$output_dir" diff --git a/native/replay/src/analysis/casts.rs b/native/replay/src/analysis/casts.rs index 18434efa7..cfef5395a 100644 --- a/native/replay/src/analysis/casts.rs +++ b/native/replay/src/analysis/casts.rs @@ -5,12 +5,15 @@ //! `CastTime` counts from the round's buy-phase end, so a cast happened at //! `combatStartMs + CastTime` ("`CastTime` is not measured from roundStarted"). //! -//! The array names a slot, not an ability. A cast lands within a few -//! milliseconds of the charge (`AuthResourceAmount`) its ability item spends -//! (0-3 ms on 1fb53a2c), so each (player, slot) takes the item class those -//! decrements agree on. -//! Where they do not agree, or never fire (an ultimate spends no charge), -//! `classPath` stays absent. +//! The array names a slot, and the slot names the ability: 3 grenade, 4 +//! ability one, 5 ability two, 9 ultimate, valorant-api.com's `Grenade`, +//! `Ability1`, `Ability2` and `Ultimate` (every cast of 26 agents across our +//! 7 replays agrees with the class below). The class adds the ability item +//! itself: a cast lands within a few milliseconds of the charge +//! (`AuthResourceAmount`) its item spends (0-3 ms on 1fb53a2c), so each +//! (player, slot) takes the item class those decrements agree on. Where they +//! do not agree, or never fire (an ultimate spends no charge), `classPath` +//! stays absent. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -135,13 +138,6 @@ pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { "{unplaced} cast(s) in a round with no buy-phase end; timed by first send" )); } - let unnamed = casts.iter().filter(|c| c.class_path.is_none()).count(); - if unnamed > 0 { - cx.warnings.push(format!( - "{unnamed} of {} cast(s) without an ability class", - casts.len() - )); - } casts } diff --git a/native/replay/src/analysis/mod.rs b/native/replay/src/analysis/mod.rs index b7446199d..f1ca5b993 100644 --- a/native/replay/src/analysis/mod.rs +++ b/native/replay/src/analysis/mod.rs @@ -200,6 +200,20 @@ pub(crate) fn parse_vector(text: &str) -> Option { (parts.next().is_none() && v.iter().all(|c| c.is_finite())).then_some(v) } +/// A vector parameter vrfkit leaves untyped: 192 bits, three little-endian +/// f64 (UE5's double `FVector`). Typed so only where a field's values were +/// checked against the world: Fast Lane's points step 125 cm along a line +/// from the actor, Shear's start is its actor's spawn, Barrier Mesh's +/// `RootLocation` is its root's. +pub(crate) fn raw_vector(raw: &[u8]) -> Option { + if raw.len() != 24 { + return None; + } + let f = |i: usize| f64::from_le_bytes(raw[i..i + 8].try_into().expect("8 bytes")); + let v = [f(0), f(8), f(16)]; + v.iter().all(|c| c.is_finite()).then_some(v) +} + /// The result of the analysis: the JSON document and the movement blob. pub struct Analysed { pub document: Document, @@ -228,7 +242,6 @@ pub(crate) fn analyse( subject: p.subject.clone(), agent_id: agents.get(&p.subject).cloned().flatten(), team: teams.get(&p.subject).cloned(), - name: None, character_guids: p.bodies.clone(), }) .collect(); @@ -249,8 +262,12 @@ pub(crate) fn analyse( let rounds = rounds::rounds(&mut cx); let kills = kills::kills(&mut cx, &teams); - let vitals = vitals::vitals(&mut cx); - let utility = utility::utility(&mut cx); + let vitals = vitals::vitals(&mut cx, &rounds); + let utility = utility::utility( + &mut cx, + &collector.moves, + rounds.first().map(|r| r.start_ms), + ); let casts = casts::casts(&mut cx, &rounds); let (movement, blob) = movement::movement(&mut cx, &collector.moves); @@ -381,6 +398,19 @@ mod tests { assert_eq!(parse_vector("(NaN,0,0)"), None); } + #[test] + fn untyped_vectors_are_three_little_endian_doubles() { + // A Fast Lane point from c8989335, as its 24 payload bytes. + let mut raw = Vec::new(); + for v in [6081.25_f64, -4858.5, 210.0] { + raw.extend(v.to_le_bytes()); + } + assert_eq!(raw_vector(&raw), Some([6081.25, -4858.5, 210.0])); + assert_eq!(raw_vector(&raw[..16]), None, "two doubles are not a vector"); + raw[..8].copy_from_slice(&f64::NAN.to_le_bytes()); + assert_eq!(raw_vector(&raw), None); + } + #[test] fn owner_lookup_prefers_the_last_prior_non_zero_write() { let writes = [(10, 5), (20, 0), (30, 7)]; diff --git a/native/replay/src/analysis/movement.rs b/native/replay/src/analysis/movement.rs index a926d4e13..2eee91765 100644 --- a/native/replay/src/analysis/movement.rs +++ b/native/replay/src/analysis/movement.rs @@ -1,22 +1,19 @@ //! Movement records (docs/replay-format.md, "Movement records"): every -//! decoded move of every body a player had, sorted by time, minus the -//! moves of a body parked off-map between lives. +//! decoded move of every body a player had, sorted by time. +//! +//! Positions are kept wherever they are. The only off-map ones in our corpus +//! (c8989335, 5,245 records around x -50000, z -49900) are the two players of +//! each Iso Kill Contract, which duels them in an arena built off the map; +//! vrfkit docs/DATA.md reads that spot as hidden actors parking, but every +//! such record falls in a Kill Contract window. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use super::Context; use crate::collect::Move; use crate::container::MovementSample; use crate::document::MovementSpan; -/// docs/DATA.md ("Minimap projection"): hidden actors park at x ≈ -50000, -/// z ≈ -49900; both are checked, since a fall passes through that z alone. -/// A parked body drifts: c8989335's 5,245 parked moves span x -51020..-49008 -/// but z only -49919..-49900. -fn parked(m: &Move) -> bool { - (m.pos[0] + 50_000.0).abs() < 5_000.0 && (m.pos[2] + 49_900.0).abs() < 100.0 -} - /// The wire's pitch is 0..360 with looking down past 360; the contract's is /// signed, positive up. fn signed_pitch(pitch: f32) -> f32 { @@ -27,13 +24,12 @@ pub(super) fn movement( cx: &mut Context<'_>, moves: &[Move], ) -> (BTreeMap, Vec) { - let mut by_body: std::collections::HashMap> = std::collections::HashMap::new(); + let mut by_body: HashMap> = HashMap::new(); for m in moves { by_body.entry(m.character).or_default().push(m); } let mut blob = Vec::new(); let mut spans = BTreeMap::new(); - let mut dropped = 0usize; for p in &cx.players { let mut mine: Vec<&Move> = p .bodies @@ -41,9 +37,6 @@ pub(super) fn movement( .filter(|b| cx.body_subject.get(b) == Some(&p.subject)) .flat_map(|b| by_body.get(b).into_iter().flatten().copied()) .collect(); - let before = mine.len(); - mine.retain(|m| !parked(m)); - dropped += before - mine.len(); // Stable: moves sharing a millisecond keep wire order. mine.sort_by_key(|m| m.time); let offset = blob.len() as u64; @@ -66,57 +59,58 @@ pub(super) fn movement( }, ); } - // Pawns that move but are no `SpawnedCharacter` body: possessed devices, - // Clove's post-death form. Named by class so a lost body would show. - let mut unclaimed: BTreeMap = BTreeMap::new(); - for (guid, list) in &by_body { - if !cx.body_subject.contains_key(guid) { - let class = cx - .actor_class - .get(guid) - .map_or("?", |c| c.rsplit('.').next().unwrap_or(c)); - *unclaimed.entry(class.to_owned()).or_default() += list.len(); - } - } - if !unclaimed.is_empty() { - let list: Vec = unclaimed.iter().map(|(c, n)| format!("{c} {n}")).collect(); + // An agent body no player claims is movement lost from the document. + // Other pawns move too (drones, turrets, Clove's post-death form); they + // are utility or no one's position, so they are not counted. + let lost: usize = by_body + .iter() + .filter(|(guid, _)| !cx.body_subject.contains_key(guid)) + .filter(|(guid, _)| cx.actor_class.get(guid).is_some_and(|c| is_agent_body(c))) + .map(|(_, list)| list.len()) + .sum(); + if lost > 0 { cx.warnings.push(format!( - "movement of pawns that are no player's body: {}", - list.join(", ") + "{lost} movement record(s) of agent bodies no player claims (dropped)" )); } - if dropped > 0 { - cx.warnings - .push(format!("{dropped} parked movement record(s) dropped")); - } (spans, blob) } +/// `/Game/Characters//_PC._PC_C`: a player's body. +fn is_agent_body(class_path: &str) -> bool { + let mut parts = class_path.split('/'); + let (Some(""), Some("Game"), Some("Characters"), Some(agent), Some(leaf), None) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return false; + }; + leaf == format!("{agent}_PC.{agent}_PC_C") +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn parking_needs_both_x_and_z() { - let at = |x: f32, z: f32| Move { - time: 0, - character: 1, - pos: [x, 0.0, z], - yaw: 0.0, - pitch: 0.0, - }; - assert!(parked(&at(-50_000.0, -49_900.0))); - assert!(parked(&at(-51_020.0, -49_919.0)), "a drifting parked body"); - assert!(!parked(&at(-50_000.0, 100.0))); - assert!( - !parked(&at(1_000.0, -49_900.0)), - "a fall passes through that z" - ); - } - #[test] fn pitch_is_signed() { assert_eq!(signed_pitch(10.0), 10.0); assert_eq!(signed_pitch(350.0), -10.0); } + + #[test] + fn agent_bodies_are_told_from_other_pawns() { + assert!(is_agent_body("/Game/Characters/Wushu/Wushu_PC.Wushu_PC_C")); + assert!(!is_agent_body( + "/Game/Characters/Smonk/PostDeath/Smonk_PostDeath_PC.Smonk_PostDeath_PC_C" + )); + assert!(!is_agent_body( + "/Game/Characters/Killjoy/S0/Ability_E/Pawn_Killjoy_E_Turret.Pawn_Killjoy_E_Turret_C" + )); + assert!(!is_agent_body("/Game/Characters/Wushu/Wushu_PC.Other_C")); + } } diff --git a/native/replay/src/analysis/rounds.rs b/native/replay/src/analysis/rounds.rs index e6ecd14a1..e42e8b1a4 100644 --- a/native/replay/src/analysis/rounds.rs +++ b/native/replay/src/analysis/rounds.rs @@ -61,7 +61,8 @@ struct Placed { pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { let mut phases: Vec = Vec::new(); - let mut repeated = 0; + // (round ordinal, phase, kept time, repeat time). + let mut repeated: Vec<(usize, i64, u32, u32)> = Vec::new(); let mut results: HashMap = HashMap::new(); let mut sites = Vec::new(); for row in cx.rows { @@ -73,15 +74,15 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { if phase == 2 || phases.is_empty() { phases.push(Phases::default()); } + let ordinal = phases.len() - 1; let slot = phases .last_mut() .expect("pushed") .slot(phase) .expect("2..=6"); - if slot.is_none() { - *slot = Some(row.time); - } else { - repeated += 1; + match *slot { + None => *slot = Some(row.time), + Some(kept) => repeated.push((ordinal, phase, kept, row.time)), } } else if &*row.name == "PlantedAtSite" { sites.push((row.time, row.int.unwrap_or(0))); @@ -96,9 +97,12 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { .insert(path.leaf().to_owned(), (row.int, row.text.clone())); } } - if repeated > 0 { + // Seen on 13.00: the buy phase ending twice, 0.4-0.9 s apart. The first + // is kept, so `combatStartMs` and the cast times built on it could be + // that much early. + for (ordinal, phase, kept, again) in repeated { cx.warnings.push(format!( - "{repeated} game phase(s) repeated within a round (first kept)" + "round {ordinal}: game phase {phase} sent again at {again} ms; the first, at {kept} ms, is kept" )); } // The last side switch resets into a round that never starts. @@ -175,17 +179,21 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { .map(|(t, _)| t) .or(p.start) .expect("rounds without a start are dropped"); + let result = round_number.and_then(|(_, n)| by_number.get(&n).copied()); + let winning_team = result.and_then(|r| text(r, "WinningTeam")); + // A match surrendered mid-round ends with no end phase but a result; + // only a round with neither was cut off. let end = match p.post_round.or_else(|| opens.get(ordinal + 1).copied()) { Some(end) => i64::from(end), None => { - cx.warnings.push(format!( - "round {ordinal} has no end phase; it ends with the replay" - )); + if winning_team.is_none() { + cx.warnings.push(format!( + "round {ordinal} never ended: the replay stops inside it" + )); + } cx.duration_ms } }; - let result = round_number.and_then(|(_, n)| by_number.get(&n).copied()); - let winning_team = result.and_then(|r| text(r, "WinningTeam")); let role = result.and_then(|r| text(r, "WinningTeamRole")); let attacking_team = match (winning_team.as_deref(), role.as_deref()) { (Some(team), Some("attacker")) => Some(team.to_owned()), @@ -424,7 +432,6 @@ fn economy(cx: &Context<'_>, buy_end: Option) -> Vec { Economy { subject: p.subject.clone(), credits: money.get(&p.state).copied(), - loadout_value: None, weapon: gun(false).or_else(|| gun(true)), armor: items.iter().filter_map(|c| armor_points(c)).max(), } diff --git a/native/replay/src/analysis/utility.rs b/native/replay/src/analysis/utility.rs index 1e92f6a23..b6ff62068 100644 --- a/native/replay/src/analysis/utility.rs +++ b/native/replay/src/analysis/utility.rs @@ -4,22 +4,48 @@ //! path segment), minus the held ability items themselves (`Ability_*`, //! `Equippable_*`), which live in inventories rather than the world. //! Owners come from `Owner`/`Instigator` as that tool reads them, followed to -//! a player's body (`Context::subject_behind`). +//! a player's body (`Context::subject_behind`). Actors the match places as +//! the first round starts (Iso's Kill Contract arenas, off the map) belong to +//! no one; anything later should have a thrower. +//! +//! A path is where the actor went: `ReplicatedMovement` for projectiles and +//! moving walls, and for pawns (Boom Bot, drones, cameras, Wingman) the +//! movement records vrfkit decodes for every pawn, thinned to about 10 Hz. +//! Shape points come from each family's own RPCs (docs/replay-format.md, +//! "Utility shape points"). use std::collections::HashMap; -use super::{Context, parse_vector}; +use super::{Context, parse_vector, raw_vector, value_at}; +use crate::collect::Move; use crate::document::{Utility, Vec3}; -pub(super) fn utility(cx: &mut Context<'_>) -> Vec { - // ReplicatedMovement locations and wall points, per actor, in order. - let mut moves: HashMap> = HashMap::new(); - let mut points: HashMap> = HashMap::new(); +/// Kept path points are at least this far apart in time, about 10 Hz; the +/// last point is always kept. +const PATH_STEP_MS: f64 = 100.0; + +pub(super) fn utility( + cx: &mut Context<'_>, + moves: &[Move], + first_round_ms: Option, +) -> Vec { + // Where each actor was, `[time, x, y, z]`, and its shape points + // `(time, point)`, in wire order. + let mut tracks: HashMap> = HashMap::new(); + let mut points: HashMap> = HashMap::new(); + // Barrier Mesh arms: each deployer's destination, filed under the root + // that owns it once the deployers' owners are known. + let mut destinations: Vec<(u32, u32, Vec3)> = Vec::new(); for row in cx.rows.iter().filter(|r| r.object.is_none()) { + let mut point = |p: Option| { + if let Some(p) = p { + points.entry(row.actor).or_default().push((row.time, p)); + } + }; match &*row.name { "ReplicatedMovement" => { if let Some(loc) = row.text.as_deref().and_then(movement_location) { - moves.entry(row.actor).or_default().push([ + tracks.entry(row.actor).or_default().push([ f64::from(row.time), loc[0], loc[1], @@ -27,14 +53,52 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { ]); } } + // Phoenix's Blaze and Viper's Toxic Screen. "MulticastAddSmokeScreenPoint.Translation" => { - if let Some(p) = row.text.as_deref().and_then(parse_vector) { - points.entry(row.actor).or_default().push(p); + point(row.text.as_deref().and_then(parse_vector)); + } + // Neon's Fast Lane, then Vyse's Shear wall and its trap. + "MulticastAddTunnelPoint.Translation" + | "MulticastInitializeWall.WallStartLocation" + | "MulticastInitializeWall.WallEndLocation" + | "MulticastInitializeTrapAnchors.WallStartPoint" + | "MulticastInitializeTrapAnchors.WallEndPoint" => { + point(row.raw.as_deref().and_then(raw_vector)); + } + "MulticastInitialize.Destination" => { + if let Some(p) = row.raw.as_deref().and_then(raw_vector) { + destinations.push((row.time, row.actor, p)); } } _ => {} } } + for m in moves { + if cx + .actor_class + .get(&m.character) + .is_some_and(|c| is_utility(c)) + { + let [x, y, z] = m.pos.map(f64::from); + tracks + .entry(m.character) + .or_default() + .push([f64::from(m.time), x, y, z]); + } + } + for track in tracks.values_mut() { + // Stable: rows sharing a millisecond keep wire order. + track.sort_by(|a, b| a[0].total_cmp(&b[0])); + } + for (time, deployer, p) in destinations { + let root = cx + .owner_writes + .get(&deployer) + .and_then(|w| value_at(w, time)); + if let Some(root) = root { + points.entry(root).or_default().push((time, p)); + } + } let mut open: HashMap = HashMap::new(); let mut out: Vec = Vec::new(); @@ -52,7 +116,7 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { (Some(x), Some(y), Some(z)) => Some([f64::from(x), f64::from(y), f64::from(z)]), _ => None, }; - let first_move = moves.get(&a.actor_net_guid).and_then(|m| { + let first_move = tracks.get(&a.actor_net_guid).and_then(|m| { m.iter() .find(|p| p[0] >= f64::from(a.time_ms)) .map(|p| [p[1], p[2], p[3]]) @@ -64,7 +128,7 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { let owner = cx .subject_behind(a.actor_net_guid, a.time_ms) .map(str::to_owned); - if owner.is_none() { + if owner.is_none() && first_round_ms.is_some_and(|t| i64::from(a.time_ms) > t) { no_owner += 1; } open.insert(a.actor_net_guid, out.len()); @@ -75,7 +139,10 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { spawn_ms: i64::from(a.time_ms), end_ms: None, position, - yaw: a.spawn_yaw.map(f64::from), + // A spawn block carries its rotation only when it is not + // zero (vrf-net's spawn.rs reads it behind a flag bit), so + // a spawned actor without one faces yaw 0. + yaw: a.spawn_yaw.map(f64::from).or(spawn.map(|_| 0.0)), path: None, points: None, }); @@ -89,28 +156,38 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { _ => {} } } + trapwire_far_ends(cx, &out, &mut points); for u in &mut out { let within = |t: f64| t >= u.spawn_ms as f64 && u.end_ms.is_none_or(|e| t <= e as f64); - if let Some(m) = moves.get(&u.id) { - let mut path: Vec<[f64; 4]> = Vec::new(); - for p in m.iter().filter(|p| within(p[0])) { - // Consecutive repeats of one location say nothing new. - if path.last().is_none_or(|l| l[1..] != p[1..]) { - path.push(*p); - } - } + if let Some(track) = tracks.get(&u.id) { + let path = thin(track.iter().copied().filter(|p| within(p[0]))); if path.len() > 1 || path.first().is_some_and(|p| p[1..] != u.position[..]) { - u.path = Some(path); + // Track times are whole milliseconds off the wire. + u.path = Some( + path.iter() + .map(|p| (p[0] as i64, p[1], p[2], p[3])) + .collect(), + ); } } - if let Some(p) = points.get(&u.id) { - u.points = Some(p.clone()); + let mine: Vec = points + .get(&u.id) + .into_iter() + .flatten() + .filter(|(t, _)| within(f64::from(*t))) + .map(|&(_, p)| p) + .collect(); + if !mine.is_empty() { + u.points = Some(mine); } } for (n, what) in [ (no_position, "utility actor(s) with no position (dropped)"), (reopened, "utility actor GUID(s) reopened while open"), - (no_owner, "utility actor(s) with no player owner"), + ( + no_owner, + "utility actor(s) spawned in play with no player owner", + ), ] { if n > 0 { cx.warnings.push(format!("{n} {what}")); @@ -119,6 +196,57 @@ pub(super) fn utility(cx: &mut Context<'_>) -> Vec { out } +/// Cypher's Trapwire opens with a `_SecondWire` actor at its far anchor, in +/// the same millisecond and under the same `Owner`; that anchor becomes the +/// wire's one shape point. +fn trapwire_far_ends( + cx: &Context<'_>, + out: &[Utility], + points: &mut HashMap>, +) { + let owner = |u: &Utility| { + let time = u32::try_from(u.spawn_ms).ok()?; + cx.owner_writes.get(&u.id).and_then(|w| value_at(w, time)) + }; + let is = |u: &Utility, asset: &str| u.class_path.rsplit('.').next() == Some(asset); + for far in out + .iter() + .filter(|u| is(u, "GameObject_Gumshoe_E_TripWire_SecondWire_C")) + { + let wires: Vec<&Utility> = out + .iter() + .filter(|u| is(u, "GameObject_Gumshoe_E_TripWire_C")) + .filter(|u| u.spawn_ms == far.spawn_ms && owner(u).is_some() && owner(u) == owner(far)) + .collect(); + if let [wire] = wires.as_slice() { + let time = u32::try_from(far.spawn_ms).unwrap_or(u32::MAX); + points + .entry(wire.id) + .or_default() + .push((time, far.position)); + } + } +} + +/// Consecutive repeats dropped, then one point per [`PATH_STEP_MS`], and the +/// last. +fn thin(track: impl Iterator) -> Vec<[f64; 4]> { + let mut moved: Vec<[f64; 4]> = Vec::new(); + for p in track { + if moved.last().is_none_or(|l| l[1..] != p[1..]) { + moved.push(p); + } + } + let last = moved.len().saturating_sub(1); + let mut path: Vec<[f64; 4]> = Vec::new(); + for (i, p) in moved.into_iter().enumerate() { + if i == last || path.last().is_none_or(|k| p[0] - k[0] >= PATH_STEP_MS) { + path.push(p); + } + } + path +} + /// `/Game/Characters//…/Ability_*|Abilities/…/._C`, /// not the held `Ability_*` / `Equippable_*` item. fn is_utility(class_path: &str) -> bool { @@ -145,6 +273,24 @@ fn movement_location(json: &str) -> Option { mod tests { use super::*; + #[test] + fn paths_drop_repeats_and_keep_ten_points_a_second_and_the_last() { + let at = |t: f64, x: f64| [t, x, 0.0, 0.0]; + let track = [ + at(0.0, 0.0), + at(8.0, 0.0), + at(16.0, 1.0), + at(96.0, 2.0), + at(120.0, 3.0), + at(130.0, 4.0), + ]; + assert_eq!( + thin(track.into_iter()), + vec![at(0.0, 0.0), at(120.0, 3.0), at(130.0, 4.0)] + ); + assert_eq!(thin(std::iter::empty()), Vec::<[f64; 4]>::new()); + } + #[test] fn world_objects_are_utility_and_held_items_are_not() { assert!(is_utility( diff --git a/native/replay/src/analysis/vitals.rs b/native/replay/src/analysis/vitals.rs index 8df137703..15af586b5 100644 --- a/native/replay/src/analysis/vitals.rs +++ b/native/replay/src/analysis/vitals.rs @@ -1,178 +1,383 @@ -//! Health and armour per player, from the absolute `LifeResult` the damage, -//! heal and reset RPCs carry per damage section (docs/DATA.md, "Health is -//! absolute, not a subtraction"; vrfkit's section tools keep the raw view). +//! Health and armour per player (docs/replay-format.md, "vitals"). //! -//! Health is the body's `HealthDamageSection`. Armour is the -//! `AttachedDamageSection` of the armour item the body owns ("Armour is -//! `AttachedDamageSection`, not `ShieldDamageSection`"), which also reports -//! through `MulticastNotifySetLife` while a regenerating shield refills. -//! A section's kind comes from its name in the GUID table. Armour reads -//! `null` until the wire states it: a bought item's full value is never -//! replicated, only later changes (the round's economy carries the tier). +//! Health is the absolute `LifeResult` that the damage, heal and reset RPCs +//! carry per damage section (vrfkit docs/DATA.md, "Health is absolute, not a +//! subtraction"), read off each body's `HealthDamageSection`. Every round +//! opens at 100: the game resets every body with `MulticastSectionLifeChange` +//! a millisecond after `roundStarted`, except in the first round, whose bodies +//! spawn full and are never reset. A revive (Sage, Clove) is the same reset. +//! +//! Armour is an item with an `AttachedDamageSection` ("Armour is +//! `AttachedDamageSection`, not `ShieldDamageSection`"). Bought, it opens +//! owned by the buyer's body, at its class's full points: a full value is +//! never replicated, only later ones, through the damage RPCs and, while a +//! regen shield refills, `MulticastNotifySetLife`. A player wears the item +//! last assigned to one of their bodies until it closes (sold back, or gone +//! after a round they died in). An `Owner` of 0 changes nothing: survivors' +//! items are unowned for the moment between rounds and come back to their +//! next body, and a replaced item is superseded by its replacement. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; -use super::{Context, value_at}; +use super::Context; +use crate::document::{Round, VitalsRow}; use crate::fieldpath::FieldPath; -use crate::document::VitalsRow; - -/// One element of one life-change call: (packet, actor, component, element -/// indices, function). -type ElementKey = (u32, u32, Option, Vec>, String); +/// Health every body starts a round with. +const FULL_HEALTH: f64 = 100.0; -/// What a subject's vitals stand at, and which armour section the armour is. -#[derive(Default, Clone, Copy, PartialEq)] -struct Vital { - health: Option, - armor: Option, - armor_section: Option, +/// One thing that moves a player's vitals, at a time. +#[derive(Debug, Clone, PartialEq)] +enum Change { + RoundStart, + Health { + subject: String, + value: f64, + }, + /// An armour item handed to one of the player's bodies. + Assigned { + subject: String, + item: u32, + }, + Reported { + item: u32, + value: f64, + }, + Closed { + item: u32, + }, } -#[derive(Clone, Copy, PartialEq)] -enum Section { - Health, - Armor, -} +pub(super) fn vitals(cx: &mut Context<'_>, rounds: &[Round]) -> BTreeMap> { + let mut changes: Vec<(u32, Change)> = rounds + .iter() + .filter_map(|r| Some((u32::try_from(r.start_ms).ok()?, Change::RoundStart))) + .collect(); -pub(super) fn vitals(cx: &mut Context<'_>) -> BTreeMap> { - // Every reported section result, in wire order: (time, body, section, kind, value). - let mut changes: Vec<(u32, u32, u32, Section, f64)> = Vec::new(); - let mut pending: HashMap, Option)> = HashMap::new(); - let mut order = Vec::new(); + // Armour items and their full points, by actor. + let full: HashMap = cx + .actor_class + .iter() + .filter_map(|(&item, class)| Some((item, super::rounds::armor_points(class)? as f64))) + .collect(); + for (&item, writes) in &cx.owner_writes { + if !full.contains_key(&item) { + continue; + } + for &(time, owner) in writes { + if let Some(subject) = cx.body_subject.get(&owner) { + let subject = subject.clone(); + changes.push((time, Change::Assigned { subject, item })); + } + } + } + for a in cx.actors { + if a.event == "close" && full.contains_key(&a.actor_net_guid) { + changes.push(( + a.time_ms, + Change::Closed { + item: a.actor_net_guid, + }, + )); + } + } + + // Every section result, in wire order: one life-change element's + // `ChangedComponent` and `LifeResult` arrive as separate rows of one call. + let mut results: Vec<(u32, u32, f64)> = Vec::new(); + let mut elements: HashMap, (Option, Option)> = HashMap::new(); + let mut order: Vec<(u32, ElementKey<'_>)> = Vec::new(); for row in cx.rows { + if &*row.name == "MulticastNotifySetLife.NewLife" { + if let (Some(section), Some(value)) = (row.object, row.float) { + results.push((row.time, section, value)); + } + continue; + } let path = FieldPath::parse(&row.name); - if path.segments.len() == 3 - && matches!( + if path.segments.len() != 3 + || !matches!( path.segments[1].base, "LifeChangeEvents" | "LifeChangeBySection" ) { - let key = ( - row.packet, - row.actor, - row.object, - path.indices(), - path.segments[0].base.to_owned(), - ); - let slot = pending.entry(key.clone()).or_insert_with(|| { - order.push((row.time, key)); - (None, None) - }); - match path.leaf() { - "ChangedComponent" => slot.0 = row.int.and_then(|v| u32::try_from(v).ok()), - "LifeResult" => slot.1 = row.float, - _ => {} - } - } else if &*row.name == "MulticastNotifySetLife.NewLife" { - // On the armour item's section: the item, not a body, is the actor. - let (Some(section), Some(value)) = (row.object, row.float) else { - continue; - }; - if section_kind(cx, section) != Some(Section::Armor) { - continue; - } - let owner = cx - .owner_writes - .get(&row.actor) - .and_then(|w| value_at(w, row.time)); - if let Some(body) = owner.filter(|b| cx.body_subject.contains_key(b)) { - changes.push((row.time, body, section, Section::Armor, value)); - } + continue; + } + let key = ( + row.packet, + row.actor, + row.object, + path.indices(), + path.segments[0].base, + ); + let slot = elements.entry(key.clone()).or_insert_with(|| { + order.push((row.time, key)); + (None, None) + }); + match path.leaf() { + "ChangedComponent" => slot.0 = row.int.and_then(|v| u32::try_from(v).ok()), + "LifeResult" => slot.1 = row.float, + _ => {} } } for (time, key) in order { - let body = key.1; - let (Some(section), Some(value)) = pending[&key] else { - continue; - }; - if !cx.body_subject.contains_key(&body) { - continue; - } - // Shield, overheal and agent sections are neither health nor armour. - if let Some(kind) = section_kind(cx, section) { - changes.push((time, body, section, kind, value)); + if let (Some(section), Some(value)) = elements[&key] { + results.push((time, section, value)); } } - // An armour item changing hands: its new owner's armour is that item's, - // unknown until it reports (NaN marks "unknown"). - for (item, section) in armor_sections(cx) { - for &(time, owner) in cx.owner_writes.get(&item).into_iter().flatten() { - if cx.body_subject.contains_key(&owner) { - changes.push((time, owner, section, Section::Armor, f64::NAN)); + + // A section is a body's health or an armour item's armour by its name + // and outer in the GUID table; shield, overheal and ability sections are + // neither. + let mut health_results = 0; + for (time, section, value) in results { + let Some(outer) = cx.cache.get_outer_guid(section).map(|o| o.0) else { + continue; + }; + match cx.cache.get_path_by_guid(section) { + Some("HealthDamageSection") => { + if let Some(subject) = cx.body_subject.get(&outer) { + health_results += 1; + let subject = subject.clone(); + changes.push((time, Change::Health { subject, value })); + } + } + Some("AttachedDamageSection") if full.contains_key(&outer) => { + changes.push((time, Change::Reported { item: outer, value })); } + _ => {} } } + if health_results == 0 { + cx.warnings + .push("no health results replicated: vitals hold only each round's start".to_owned()); + } + + // Stable: round starts lead their millisecond, the wire keeps its order. changes.sort_by_key(|c| c.0); + let subjects: Vec<&str> = cx.players.iter().map(|p| p.subject.as_str()).collect(); + fold(&subjects, &full, changes) +} + +/// One life-change element: (packet, actor, component, element indices, +/// function). +type ElementKey<'r> = (u32, u32, Option, Vec>, &'r str); - // Per subject: health, armour, and the armour section it belongs to. - let mut state: HashMap = HashMap::new(); +/// Play `changes` (in time order) into rows: one per player at every round +/// start, then one per change. Health is unknown until the first round +/// starts, so no row precedes it. +fn fold( + subjects: &[&str], + full: &HashMap, + changes: Vec<(u32, Change)>, +) -> BTreeMap> { + let mut health: HashMap = HashMap::new(); + let mut worn: HashMap = HashMap::new(); + let mut wearer: HashMap = HashMap::new(); + let mut reported: HashMap = HashMap::new(); + let mut closed: HashSet = HashSet::new(); let mut out: BTreeMap> = BTreeMap::new(); - for (time, body, section, kind, value) in changes { - let subject = cx.body_subject[&body].clone(); - let current = state.entry(subject.clone()).or_default(); - let next = match kind { - Section::Health => Vital { - health: Some(round2(value)), - ..*current - }, - // The same item handed back (a survivor at round start) keeps its value. - Section::Armor if value.is_nan() && current.armor_section == Some(section) => *current, - Section::Armor => Vital { - armor: (!value.is_nan()).then(|| round2(value)), - armor_section: Some(section), - ..*current - }, + let mut started = false; + + for (time, change) in changes { + let round_start = change == Change::RoundStart; + started |= round_start; + let touched: Vec = match change { + Change::RoundStart => { + for &s in subjects { + health.insert(s.to_owned(), FULL_HEALTH); + } + subjects.iter().map(|&s| s.to_owned()).collect() + } + Change::Health { subject, value } => { + health.insert(subject.clone(), round2(value)); + vec![subject] + } + Change::Assigned { subject, item } => { + worn.insert(subject.clone(), item); + wearer.insert(item, subject.clone()); + vec![subject] + } + Change::Reported { item, value } => { + reported.insert(item, round2(value)); + wearer.get(&item).cloned().into_iter().collect() + } + Change::Closed { item } => { + closed.insert(item); + wearer.get(&item).cloned().into_iter().collect() + } }; - let changed = (next.health, next.armor) != (current.health, current.armor); - *current = next; - if !changed { + if !started { continue; } - let rows = out.entry(subject).or_default(); - let t = i64::from(time); - // One row per change: a later change at the same millisecond replaces it. - if rows.last().is_some_and(|r| r.0 == t) { - rows.pop(); + for subject in touched { + let Some(&h) = health.get(&subject) else { + continue; + }; + let armor = worn + .get(&subject) + .filter(|item| !closed.contains(item)) + .and_then(|item| reported.get(item).or(full.get(item))) + .copied() + .unwrap_or(0.0); + let rows = out.entry(subject).or_default(); + push(rows, (i64::from(time), h, armor), round_start); } - rows.push((t, next.health, next.armor)); - } - if out.is_empty() { - cx.warnings - .push("no health or armour results replicated".to_owned()); } out } -/// Armour item actor -> its `AttachedDamageSection` subobject. -fn armor_sections(cx: &Context<'_>) -> Vec<(u32, u32)> { - cx.cache - .net_guid_entries() - .into_iter() - .filter(|e| e.path == "AttachedDamageSection") - .filter_map(|e| { - let item = e.outer_net_guid?; - let class = cx.actor_class.get(&item)?; - super::rounds::armor_points(class).map(|_| (item, e.net_guid)) - }) - .collect() -} - -/// What a damage section is, by its object name in the GUID table. -fn section_kind(cx: &Context<'_>, section: u32) -> Option
{ - match cx.cache.get_path_by_guid(section)? { - "HealthDamageSection" => Some(Section::Health), - "AttachedDamageSection" => { - // Only an armour item's section: its outer is the item actor. - let outer = cx.cache.get_outer_guid(section)?.0; - let class = cx.actor_class.get(&outer)?; - super::rounds::armor_points(class).map(|_| Section::Armor) +/// Append `row` unless it repeats the last row's values and is not forced; +/// a row at the last row's millisecond replaces it. +fn push(rows: &mut Vec, row: VitalsRow, force: bool) { + if let Some(last) = rows.last() { + if !force && (last.1, last.2) == (row.1, row.2) { + return; + } + if last.0 == row.0 { + rows.pop(); } - _ => None, } + rows.push(row); } /// The wire's f32 results to two decimals: 99.99999 and 100 are one value. fn round2(v: f64) -> f64 { (v * 100.0).round() / 100.0 } + +#[cfg(test)] +mod tests { + use super::*; + + fn health(subject: &str, value: f64) -> Change { + Change::Health { + subject: subject.to_owned(), + value, + } + } + + fn assigned(subject: &str, item: u32) -> Change { + Change::Assigned { + subject: subject.to_owned(), + item, + } + } + + fn play(changes: Vec<(u32, Change)>) -> BTreeMap> { + let full = HashMap::from([(7, 50.0), (8, 25.0)]); + fold(&["a", "b"], &full, changes) + } + + #[test] + fn every_round_opens_at_full_health_for_every_player() { + let rows = play(vec![ + (10, Change::RoundStart), + (20, health("a", 40.0)), + (30, health("a", 0.0)), + (100, Change::RoundStart), + ]); + assert_eq!( + rows["a"], + vec![ + (10, 100.0, 0.0), + (20, 40.0, 0.0), + (30, 0.0, 0.0), + (100, 100.0, 0.0) + ] + ); + // Unchanged, yet each round still opens with a row. + assert_eq!(rows["b"], vec![(10, 100.0, 0.0), (100, 100.0, 0.0)]); + } + + #[test] + fn nothing_is_known_before_the_first_round() { + let rows = play(vec![(5, health("a", 80.0)), (10, Change::RoundStart)]); + assert_eq!(rows["a"], vec![(10, 100.0, 0.0)]); + } + + #[test] + fn a_revive_brings_health_back_after_death() { + let rows = play(vec![ + (10, Change::RoundStart), + (20, health("a", 0.0)), + (25, health("a", 100.0)), + ]); + assert_eq!(rows["a"][1..], [(20, 0.0, 0.0), (25, 100.0, 0.0)]); + } + + #[test] + fn bought_armour_is_full_until_it_reports() { + let rows = play(vec![ + (10, Change::RoundStart), + (12, assigned("a", 7)), + ( + 20, + Change::Reported { + item: 7, + value: 31.5, + }, + ), + ]); + assert_eq!( + rows["a"], + vec![(10, 100.0, 0.0), (12, 100.0, 50.0), (20, 100.0, 31.5)] + ); + } + + #[test] + fn a_survivor_keeps_damaged_armour_into_the_next_round() { + let rows = play(vec![ + (10, Change::RoundStart), + (12, assigned("a", 7)), + ( + 20, + Change::Reported { + item: 7, + value: 20.0, + }, + ), + // The item comes back to the next body after the round start. + (100, Change::RoundStart), + (101, assigned("a", 7)), + ]); + assert_eq!(rows["a"].last(), Some(&(100, 100.0, 20.0))); + } + + #[test] + fn a_replaced_or_sold_item_stops_counting() { + let rows = play(vec![ + (10, Change::RoundStart), + (12, assigned("a", 8)), + (14, assigned("a", 7)), + (15, Change::Closed { item: 8 }), + ( + 16, + Change::Reported { + item: 8, + value: 1.0, + }, + ), + (18, Change::Closed { item: 7 }), + ]); + assert_eq!( + rows["a"], + vec![ + (10, 100.0, 0.0), + (12, 100.0, 25.0), + (14, 100.0, 50.0), + (18, 100.0, 0.0) + ] + ); + } + + #[test] + fn a_later_change_in_the_same_millisecond_replaces_the_row() { + let rows = play(vec![ + (10, Change::RoundStart), + (20, health("a", 60.0)), + (20, health("a", 50.0)), + ]); + assert_eq!(rows["a"], vec![(10, 100.0, 0.0), (20, 50.0, 0.0)]); + } +} diff --git a/native/replay/src/bin/icarus-replay-dump.rs b/native/replay/src/bin/icarus-replay-dump.rs index 97b5df42e..7aa8241e6 100644 --- a/native/replay/src/bin/icarus-replay-dump.rs +++ b/native/replay/src/bin/icarus-replay-dump.rs @@ -1,6 +1,6 @@ //! Dev tool: decode one replay and write what came out, for inspection. //! -//! icarus-replay-dump [out-dir] +//! icarus-replay-dump [--survey | --rows=A,B] [out-dir] //! //! Writes `.icrp` and `.json` (the document, movement summarized) to //! `out-dir` (default: the current directory) and prints a one-line summary. @@ -8,7 +8,7 @@ //! stream carried with its row and typed-value counts, for finding facts; //! `--rows=A,B` writes `.rows.tsv`, every field row whose name or group //! contains A or B. Both also write `.guids.tsv`, the GUID table. -//! Oodle archives come from `ICARUS_OODLE_VECTORS` (feature `dev-vectors`). +//! Decompresses with the build's decoder (`oodle::default_decompressor`). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -28,7 +28,7 @@ fn main() { .collect(); let paths: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); let Some(input) = paths.first() else { - eprintln!("usage: icarus-replay-dump [--survey] [out-dir]"); + eprintln!("usage: icarus-replay-dump [--survey | --rows=A,B] [out-dir]"); std::process::exit(2); }; let out_dir = PathBuf::from(paths.get(1).map_or(".", |s| s.as_str())); @@ -137,8 +137,7 @@ impl Observer for Survey { .or(f.value_str.clone()) .unwrap_or_else(|| format!("{}b", f.bit_count)); self.rows.push_str(&format!( - "{} {} {} {} {} {name} {value} -", + "{}\t{}\t{}\t{}\t{}\t{name}\t{value}\n", f.time_ms, f.packet_id, f.actor_net_guid, @@ -274,11 +273,7 @@ fn run_survey( .iter() .map(|e| { let outer = e.outer_net_guid.map_or(String::new(), |o| o.to_string()); - format!( - "{} {outer} {} -", - e.net_guid, e.path - ) + format!("{}\t{outer}\t{}\n", e.net_guid, e.path) }) .collect(); std::fs::write( diff --git a/native/replay/src/collect.rs b/native/replay/src/collect.rs index 099a4975f..e0dcb9c16 100644 --- a/native/replay/src/collect.rs +++ b/native/replay/src/collect.rs @@ -4,7 +4,7 @@ use std::sync::Arc; -use vrf_export::{ActorRecord, EventRecord}; +use vrf_export::{ActorRecord, EventRecord, FieldRecord}; use vrf_schema::NetGuidCache; use crate::decode::Observer; @@ -25,6 +25,8 @@ pub struct Row { pub float: Option, pub boolean: Option, pub text: Option, + /// The payload of a value vrfkit left untyped, whole bytes only. + pub raw: Option>, } /// One movement sample, as compact as the analysis needs it. @@ -44,8 +46,6 @@ pub struct Collector { pub moves: Vec, pub events: Vec, pub guard: GuardSample, - /// Field rows walked in total, kept or not. - pub field_rows: u64, /// `wanted` per interned name; the `Arc` held here keeps its address unique. verdicts: std::collections::HashMap, bool)>, } @@ -65,7 +65,6 @@ impl Observer for Collector { pitch: m.pitch, })); for field in &records.fields { - self.field_rows += 1; if let Some(raw) = &field.raw_bits { self.guard.add(raw, field.bit_count); } @@ -92,6 +91,7 @@ impl Observer for Collector { float: field.value_f64, boolean: field.value_bool, text: field.value_str.clone(), + raw: untyped_bytes(field), }); } } @@ -115,6 +115,12 @@ const EXACT: &[&str] = &[ "MulticastNotifyKilledEnemy.KillerCharacter", "MulticastNotifyKilledEnemy.KilledCharacter", "MulticastAddSmokeScreenPoint.Translation", + "MulticastAddTunnelPoint.Translation", + "MulticastInitializeWall.WallStartLocation", + "MulticastInitializeWall.WallEndLocation", + "MulticastInitializeTrapAnchors.WallStartPoint", + "MulticastInitializeTrapAnchors.WallEndPoint", + "MulticastInitialize.Destination", "MulticastNotifySetLife.NewLife", "MulticastNotifyDamage_Point.bDamageKilledTarget", "MulticastNotifyDamage_Point.EquippableUsed", @@ -122,6 +128,22 @@ const EXACT: &[&str] = &[ "MulticastNotifyDamage_Base.EquippableUsed", ]; +/// The whole bytes of a value vrfkit left untyped; the analysis types the +/// few it reads (`analysis::raw_vector`). +fn untyped_bytes(field: &FieldRecord) -> Option> { + let typed = field.value_i64.is_some() + || field.value_f64.is_some() + || field.value_bool.is_some() + || field.value_str.is_some(); + let raw = field.raw_bits.as_deref()?; + (!typed && field.bit_count % 8 == 0) + .then(|| { + raw.get(..(field.bit_count / 8) as usize) + .map(<[u8]>::to_vec) + }) + .flatten() +} + fn wanted(name: &str) -> bool { if EXACT.contains(&name) { return true; diff --git a/native/replay/src/decode.rs b/native/replay/src/decode.rs index 376558539..3fb79f344 100644 --- a/native/replay/src/decode.rs +++ b/native/replay/src/decode.rs @@ -75,11 +75,8 @@ pub struct Walked { pub players: FxHashMap, pub stats: ExportStats, pub net: NetStats, - pub replay_data_chunks: u32, - pub event_chunks: u32, pub unknown_chunks: u32, pub packets: u32, - pub frames: u32, /// Event chunks whose payload did not fit the measured layout. pub event_layout_mismatches: u32, pub stream_failures: Vec, @@ -130,11 +127,8 @@ pub fn walk( players: FxHashMap::default(), stats: ExportStats::default(), net: NetStats::default(), - replay_data_chunks: 0, - event_chunks: 0, unknown_chunks: 0, packets: 0, - frames: 0, event_layout_mismatches: 0, stream_failures: Vec::new(), }; @@ -174,7 +168,6 @@ pub fn walk( )); }; let frames = inflated.map_err(|e| e.with_build(build))?; - walked.replay_data_chunks += 1; let finished = pass .walk(&frames, &mut stats, |records, cache| { observer.on_packet(records, cache); @@ -188,7 +181,6 @@ pub fn walk( } } ChunkType::Event => { - walked.event_chunks += 1; let event = parse_event(payload, &mut walked.event_layout_mismatches) .map_err(|e| e.with_build(build))?; observer.on_event(event); @@ -205,7 +197,6 @@ pub fn walk( pass.finish(); observer.on_packet(&pass.buffers, &pass.cache); walked.packets = pass.packets; - walked.frames = pass.frames; walked.net = pass.reader.stats().clone(); walked.stream_failures = pass.channels.stream_failures().to_vec(); walked.players = pass.channels.players().clone(); diff --git a/native/replay/src/document.rs b/native/replay/src/document.rs index be7aa2dcf..f37c5b3b6 100644 --- a/native/replay/src/document.rs +++ b/native/replay/src/document.rs @@ -8,7 +8,10 @@ use serde::Serialize; pub type Vec3 = [f64; 3]; /// `[timeMs, health, armor]`. -pub type VitalsRow = (i64, Option, Option); +pub type VitalsRow = (i64, f64, f64); + +/// `[timeMs, x, y, z]`: like every row, its time is an integer. +pub type PathRow = (i64, f64, f64, f64); #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -53,8 +56,6 @@ pub struct Player { pub subject: String, pub agent_id: Option, pub team: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, pub character_guids: Vec, } @@ -106,8 +107,6 @@ pub struct Economy { pub subject: String, pub credits: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub loadout_value: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub weapon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub armor: Option, @@ -139,7 +138,7 @@ pub struct Utility { #[serde(skip_serializing_if = "Option::is_none")] pub yaw: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, + pub path: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub points: Option>, } @@ -149,7 +148,8 @@ pub struct Utility { pub struct Cast { pub time_ms: i64, pub subject: String, - /// The replicated `AbilityCastsThisRound[].Slot` value. + /// `AbilityCastsThisRound[].Slot`: 3 grenade, 4 ability one, 5 ability + /// two, 9 ultimate (docs/replay-format.md). pub slot: i64, #[serde(skip_serializing_if = "Option::is_none")] pub class_path: Option, @@ -170,3 +170,36 @@ pub struct Quality { pub decode_errors: u64, pub warnings: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn row_times_serialize_as_integers() { + let utility = Utility { + id: 1, + class_path: + "/Game/Characters/Clay/S0/Ability_E/Pawn_Clay_E_Boomba.Pawn_Clay_E_Boomba_C" + .to_owned(), + owner: None, + spawn_ms: 50_000, + end_ms: None, + position: [0.0, 0.0, 0.0], + yaw: Some(0.0), + path: Some(vec![(50_806, 1.5, -2.0, 3.0)]), + points: None, + }; + let json = serde_json::to_string(&utility).unwrap(); + assert!(json.contains("[50806,1.5,-2.0,3.0]"), "{json}"); + let back: serde_json::Value = serde_json::from_str(&json).unwrap(); + let row = &back["path"][0]; + assert!(row[0].is_i64(), "{row}"); + assert_eq!(row[0].as_i64(), Some(50_806)); + + let vitals: VitalsRow = (1203, 100.0, 50.0); + let back: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&vitals).unwrap()).unwrap(); + assert!(back[0].is_i64(), "{back}"); + } +} diff --git a/native/replay/tests/corpus.rs b/native/replay/tests/corpus.rs index 40512bb0a..1a97447e4 100644 --- a/native/replay/tests/corpus.rs +++ b/native/replay/tests/corpus.rs @@ -1,12 +1,15 @@ //! Corpus tests over real replays: set `ICARUS_REPLAY_CORPUS` to a directory -//! of `.vrf` files (skipped otherwise). Decoding needs Oodle: run with -//! `--features dev-vectors` and `ICARUS_OODLE_VECTORS` set. +//! of `.vrf` files (skipped otherwise). Each decoded replay is checked +//! against its own Event chunks and against the rules of a match. -use std::path::PathBuf; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use icarus_replay::container::{self, MOVEMENT_RECORD_BYTES, MovementSample}; use icarus_replay::decode::Control; use icarus_replay::{ErrorCode, header, oodle}; +use serde_json::Value; +use vrf_container::{ChunkIterator, ChunkType, parse_event_chunk, parse_preamble}; fn corpus() -> Vec { let Some(dir) = std::env::var_os("ICARUS_REPLAY_CORPUS") else { @@ -23,8 +26,21 @@ fn corpus() -> Vec { files } -fn decoder_available() -> bool { - cfg!(feature = "dev-vectors") && std::env::var_os("ICARUS_OODLE_VECTORS").is_some() +/// Event chunks per group, read straight off the file. +fn event_counts(path: &Path) -> HashMap { + let data = std::fs::read(path).unwrap(); + let preamble = parse_preamble(&data).unwrap(); + let mut chunks = ChunkIterator::new(&data, preamble.remaining_offset); + let mut counts = HashMap::new(); + while let Some(c) = chunks.next_chunk().unwrap() { + if c.chunk_type == ChunkType::Event { + let payload = &data[c.data_offset..][..c.size_in_bytes as usize]; + *counts + .entry(parse_event_chunk(payload).unwrap().group) + .or_default() += 1; + } + } + counts } #[test] @@ -54,16 +70,12 @@ fn a_build_without_a_decoder_reports_unsupported_compression() { #[test] fn every_replay_decodes_to_the_contract() { - if !decoder_available() { - eprintln!("no Oodle decoder: decode test skipped"); - return; - } for path in corpus() { let started = std::time::Instant::now(); let buffer = icarus_replay::decode_file(&path, Control::default()).expect("decode"); let parts = container::read(&buffer).expect("container"); assert_eq!(parts.format_version, 1); - let doc: serde_json::Value = serde_json::from_str(parts.json).expect("JSON"); + let doc: Value = serde_json::from_str(parts.json).expect("JSON"); let id = doc["match"]["id"].as_str().unwrap().to_owned(); eprintln!("{id} decoded in {:?}", started.elapsed()); @@ -72,47 +84,227 @@ fn every_replay_decodes_to_the_contract() { assert_eq!(doc["quality"]["decodeErrors"], 0); let players = doc["players"].as_array().unwrap(); - let red = players.iter().filter(|p| p["team"] == "Red").count(); - let blue = players.iter().filter(|p| p["team"] == "Blue").count(); - assert_eq!((red, blue), (5, 5), "{id}: teams"); + let team: HashMap<&str, &str> = players + .iter() + .map(|p| (p["subject"].as_str().unwrap(), p["team"].as_str().unwrap())) + .collect(); + let red = team.values().filter(|&&t| t == "Red").count(); + assert_eq!((red, team.len() - red), (5, 5), "{id}: teams"); - // Kills and rounds come one per Event-chunk death and round start. let rounds = doc["rounds"].as_array().unwrap(); - assert!(!rounds.is_empty()); - for (i, r) in rounds.iter().enumerate() { - assert_eq!(r["index"], i); + let kills = doc["kills"].as_array().unwrap(); + check_event_counts(&id, &event_counts(&path), rounds, kills); + check_score(&id, rounds); + check_sides(&id, rounds, kills, &team); + check_vitals(&id, &doc, rounds, kills, &team); + check_movement(&id, &doc, parts.blob, &team); + check_utility(&id, doc["utility"].as_array().unwrap()); + for c in doc["casts"].as_array().unwrap() { assert!( - r["startMs"].as_i64() <= r["endMs"].as_i64(), - "{id} round {i}" + [3, 4, 5, 9].contains(&c["slot"].as_i64().unwrap()), + "{id}: cast {c}" ); - assert!(r["attackingTeam"].is_string(), "{id} round {i}: attackers"); } - let kills = doc["kills"].as_array().unwrap(); - let subjects: Vec<&str> = players + } +} + +/// One round per `roundStarted`, one kill per `characterDeath`, and every +/// plant, defuse and detonation the Event chunks record. +fn check_event_counts( + id: &str, + events: &HashMap, + rounds: &[Value], + kills: &[Value], +) { + let count = |group: &str| events.get(group).copied().unwrap_or(0); + let with = |key: &str| rounds.iter().filter(|r| !r[key].is_null()).count(); + assert_eq!(rounds.len(), count("roundStarted"), "{id}: rounds"); + assert_eq!(kills.len(), count("characterDeath"), "{id}: kills"); + assert_eq!(with("plant"), count("spikePlanted"), "{id}: plants"); + assert_eq!(with("defuse"), count("spikeDefused"), "{id}: defuses"); + assert_eq!( + with("detonateMs"), + count("spikeExploded"), + "{id}: detonations" + ); +} + +/// Winners follow the score: no round is played once a team has 13 with a +/// two-round lead, and the last round decides the match unless it was +/// surrendered or the replay stops inside it. +fn check_score(id: &str, rounds: &[Value]) { + let mut score: HashMap<&str, u32> = HashMap::from([("Red", 0), ("Blue", 0)]); + let decided = |s: &HashMap<&str, u32>| { + let (r, b) = (s["Red"], s["Blue"]); + r.max(b) >= 13 && r.abs_diff(b) >= 2 + }; + for (i, r) in rounds.iter().enumerate() { + assert_eq!(r["index"], i); + assert!( + r["startMs"].as_i64() <= r["endMs"].as_i64(), + "{id} round {i}" + ); + assert!( + !decided(&score), + "{id}: round {i} played after the match was won" + ); + match r["winningTeam"].as_str() { + Some(team) => *score.get_mut(team).expect("Red or Blue") += 1, + None => assert_eq!(i, rounds.len() - 1, "{id}: round {i} has no winner"), + } + } + let last = rounds.last().expect("a round"); + assert!( + decided(&score) || last["endReason"] == "surrender" || last["winningTeam"].is_null(), + "{id}: the match ends undecided at {score:?}" + ); +} + +/// Sides switch after 12 rounds and every overtime round; the teams' own +/// acts agree: attackers plant, defenders defuse, no one kills a teammate. +fn check_sides(id: &str, rounds: &[Value], kills: &[Value], team: &HashMap<&str, &str>) { + let first = rounds[0]["attackingTeam"].as_str().unwrap(); + for (i, r) in rounds.iter().enumerate() { + let swapped = match i { + 0..12 => false, + 12..24 => true, + _ => (i - 24) % 2 == 1, + }; + let attackers = r["attackingTeam"].as_str().unwrap(); + assert_eq!(attackers != first, swapped, "{id}: round {i} attackers"); + let of = |v: &Value| team[v["subject"].as_str().expect("named")]; + if !r["plant"].is_null() { + assert_eq!(of(&r["plant"]), attackers, "{id}: round {i} planter"); + } + if !r["defuse"].is_null() { + assert_ne!(of(&r["defuse"]), attackers, "{id}: round {i} defuser"); + } + } + for k in kills { + let victim = team[k["victim"].as_str().unwrap()]; + if let Some(killer) = k["killer"].as_str() { + assert_ne!(team[killer], victim, "{id}: team kill {k}"); + } + } +} + +/// Every player opens every round at 100 health and lies at 0 after each +/// death; nothing is negative or unknown. +fn check_vitals( + id: &str, + doc: &Value, + rounds: &[Value], + kills: &[Value], + team: &HashMap<&str, &str>, +) { + let rows = |subject: &str| -> Vec<(i64, f64, f64)> { + doc["vitals"][subject] + .as_array() + .unwrap_or_else(|| panic!("{id}: no vitals for {subject}")) .iter() - .map(|p| p["subject"].as_str().unwrap()) - .collect(); - for k in kills { - assert!(subjects.contains(&k["victim"].as_str().unwrap())); + .map(|r| { + ( + r[0].as_i64().unwrap(), + r[1].as_f64().unwrap(), + r[2].as_f64().unwrap(), + ) + }) + .collect() + }; + let at = |rows: &[(i64, f64, f64)], t: i64| rows.iter().rev().find(|r| r.0 <= t).copied(); + for &subject in team.keys() { + let rows = rows(subject); + assert!( + rows.iter().all(|r| r.1 >= 0.0 && r.2 >= 0.0), + "{id} {subject}" + ); + for r in rounds { + let start = r["startMs"].as_i64().unwrap(); + assert!( + rows.iter().any(|&(t, h, _)| t == start && h == 100.0), + "{id} {subject}: round {} opens without a full-health row", + r["index"] + ); } + } + for k in kills { + let victim = k["victim"].as_str().unwrap(); + // The death's damage call follows its event by a few milliseconds. + let t = k["timeMs"].as_i64().unwrap() + 100; + assert_eq!(at(&rows(victim), t).map(|r| r.1), Some(0.0), "{id}: {k}"); + } +} + +/// Every player moves; each span is in bounds, sorted, and signed in pitch. +fn check_movement(id: &str, doc: &Value, blob: &[u8], team: &HashMap<&str, &str>) { + let movement = doc["movement"].as_object().unwrap(); + assert_eq!(movement.len(), team.len(), "{id}: movement per player"); + for (subject, span) in movement { + let offset = span["offset"].as_u64().unwrap() as usize; + let count = span["count"].as_u64().unwrap() as usize; + assert!(count > 0, "{id} {subject}: no movement"); + let bytes = &blob[offset..offset + count * MOVEMENT_RECORD_BYTES]; + let mut last = i32::MIN; + for record in bytes.chunks(MOVEMENT_RECORD_BYTES) { + let s = MovementSample::read(record); + assert!(s.time_ms >= last, "{id} {subject}: unsorted"); + assert!((-180.0..=180.0).contains(&s.pitch), "{id} {subject}: {s:?}"); + last = s.time_ms; + } + } +} - // Movement: every span in bounds, sorted, no parked record. - for (subject, span) in doc["movement"].as_object().unwrap() { - let offset = span["offset"].as_u64().unwrap() as usize; - let count = span["count"].as_u64().unwrap() as usize; - assert!(count > 0, "{id} {subject}: no movement"); - let bytes = &parts.blob[offset..offset + count * MOVEMENT_RECORD_BYTES]; - let mut last = i32::MIN; - for record in bytes.chunks(MOVEMENT_RECORD_BYTES) { - let s = MovementSample::read(record); - assert!(s.time_ms >= last, "{id} {subject}: unsorted"); - assert!( - !(s.x < -40_000.0 && s.z < -40_000.0), - "{id} {subject}: parked at {s:?}" - ); - assert!((-180.0..=180.0).contains(&s.pitch)); - last = s.time_ms; +/// Paths run inside the actor's life at no more than 10 Hz; shapes start +/// where their actor is, and a Trapwire reaches its far anchor along its yaw. +fn check_utility(id: &str, utility: &[Value]) { + let asset = |u: &Value| { + let path = u["classPath"].as_str().unwrap(); + path.rsplit('/') + .next() + .unwrap() + .split('.') + .next() + .unwrap() + .to_owned() + }; + let xy = |v: &Value| (v[0].as_f64().unwrap(), v[1].as_f64().unwrap()); + let far = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).hypot(a.1 - b.1); + for u in utility { + let (spawn, end) = (u["spawnMs"].as_f64().unwrap(), u["endMs"].as_f64()); + let path = u["path"].as_array().map_or(&[][..], Vec::as_slice); + assert!( + path.iter().all(|p| p[0].is_i64()), + "{id}: path time not an integer {u}" + ); + let times: Vec = path.iter().map(|p| p[0].as_f64().unwrap()).collect(); + assert!( + times + .iter() + .all(|&t| t >= spawn && end.is_none_or(|e| t <= e)), + "{id}: path outside its life {u}" + ); + assert!( + times.windows(2).rev().skip(1).all(|w| w[1] - w[0] >= 100.0), + "{id}: path above 10 Hz {u}" + ); + let points = u["points"].as_array().map_or(&[][..], Vec::as_slice); + let at = xy(&u["position"]); + match asset(u).as_str() { + "GameObject_Gumshoe_E_TripWire" => { + assert_eq!(points.len(), 1, "{id}: Trapwire far end {u}"); + let (x, y) = xy(&points[0]); + let toward = (y - at.1).atan2(x - at.0).to_degrees(); + let off = (toward - u["yaw"].as_f64().unwrap()).rem_euclid(360.0); + assert!(off.min(360.0 - off) < 1.0, "{id}: Trapwire yaw {u}"); } + "GameObject_Nox_Wall" | "GameObject_Sprinter_4_Tunnel" => { + assert!(points.len() >= 2, "{id}: wall points {u}"); + assert!(far(xy(&points[0]), at) < 1.0, "{id}: wall start {u}"); + } + "GameObject_CableJamRoot" => { + assert!((1..=4).contains(&points.len()), "{id}: mesh arms {u}"); + } + _ => {} } } } @@ -121,9 +313,6 @@ fn every_replay_decodes_to_the_contract() { /// both thresholds, so neither a quiet match nor a busy one trips it. #[test] fn every_replay_passes_the_guard_with_margin() { - if !decoder_available() { - return; - } let decompressor = oodle::default_decompressor(); for path in corpus() { let data = std::fs::read(&path).unwrap(); @@ -154,10 +343,6 @@ fn every_replay_passes_the_guard_with_margin() { /// the guard must refuse it rather than return an empty replay. #[test] fn a_wrong_transform_fails_the_guard() { - if !decoder_available() { - eprintln!("no Oodle decoder: guard test skipped"); - return; - } let decompressor = oodle::default_decompressor(); for path in corpus().into_iter().take(2) { let data = std::fs::read(&path).unwrap(); @@ -177,9 +362,6 @@ fn a_wrong_transform_fails_the_guard() { #[test] fn cancel_stops_a_decode() { - if !decoder_available() { - return; - } for path in corpus().into_iter().take(1) { let cancel = std::sync::atomic::AtomicU32::new(1); let err = icarus_replay::decode_file( @@ -197,9 +379,6 @@ fn cancel_stops_a_decode() { #[test] fn progress_reaches_done() { - if !decoder_available() { - return; - } for path in corpus().into_iter().take(1) { let progress = std::sync::atomic::AtomicU32::new(0); icarus_replay::decode_file( diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index b944ebd70..1491f6476 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -108,6 +108,11 @@ if ($runPackage) { if (-not (Test-Path -LiteralPath $distArchivePath)) { throw "Desktop Updater archive folder not found at $distArchivePath" } + # The replay decoder is built by cargo from windows/CMakeLists.txt; without + # it the app runs but cannot open a replay. + if (-not (Test-Path -LiteralPath (Join-Path $distArchivePath "icarus_replay.dll"))) { + throw "icarus_replay.dll was not included in the Desktop Updater archive at $distArchivePath" + } $archivedFfmpegDirectory = Join-Path $distArchivePath "ffmpeg" $archivedFfmpegPath = Join-Path $archivedFfmpegDirectory "ffmpeg.exe" $archiveHashesPath = Join-Path $distArchivePath "hashes.json" From f4fed868a79e8ab3260622c3dc310cc8380f29df Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 01:31:37 -0400 Subject: [PATCH 06/18] Stand replay cones on the replay's level; keep playback smooth Each player's view cone stands on the support level their replay height puts them on (projection.visionElevationFor), so a player under a roof sees from under it. While playing, a frame moves only the three players whose pose is oldest (and any older than 66 ms): each move recomputes a wall-cut cone, and ten in one frame held playback near 46 fps on a long Split replay; three keep it at 70+ fps. Paused, scrubbed and captured moments are exact. Size conversions the replay needs no longer depend on a laid-out canvas. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/const/coordinate_system.dart | 16 ++++- lib/replay/replay_ability_catalog.dart | 13 ++-- lib/replay/replay_frame.dart | 49 ++++++++++--- lib/replay/replay_map_projection.dart | 83 +++++++++++++++++++++ lib/replay/replay_playback.dart | 56 ++++++++++++++- lib/replay_view.dart | 24 +++++++ test/replay_frame_test.dart | 67 +++++++++++++++-- test/replay_map_projection_test.dart | 99 ++++++++++++++++++++++++++ 8 files changed, 380 insertions(+), 27 deletions(-) diff --git a/lib/const/coordinate_system.dart b/lib/const/coordinate_system.dart index 482606d5e..e08fb48e5 100644 --- a/lib/const/coordinate_system.dart +++ b/lib/const/coordinate_system.dart @@ -26,7 +26,8 @@ class CoordinateSystem { Size get effectiveSize => _effectiveSize; // The normalized coordinate space will maintain this aspect ratio static const double defaultMapAspectRatio = 1.24; - final double normalizedHeight = 1000.0; + final double normalizedHeight = _worldHeight; + static const double _worldHeight = 1000.0; final double mapAspectRatio = defaultMapAspectRatio; final double worldAspectRatio = 16 / 9; @@ -181,7 +182,18 @@ class CoordinateSystem { ); } - final double _baseHeight = 831.0; + final double _baseHeight = _virtualBaseHeight; + static const double _virtualBaseHeight = 831.0; + + /// [virtualOffsetToWorld] without an instance: the conversion is the same + /// at every screen size, so code that only converts sizes needn't wait for + /// a canvas to lay out. + static Offset virtualToWorld(Offset virtualOffset) => + virtualOffset * (_worldHeight / _virtualBaseHeight); + + /// [virtualLengthToWorld] without an instance. + static double virtualLengthInWorld(double virtualLength) => + virtualLength * (_worldHeight / _virtualBaseHeight); // Get the scale factor based on screen height double get _scaleFactor => _effectiveSize.height / _baseHeight; diff --git a/lib/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart index 38c0f1d18..8f6cfd516 100644 --- a/lib/replay/replay_ability_catalog.dart +++ b/lib/replay/replay_ability_catalog.dart @@ -97,7 +97,7 @@ class ReplayAbilityEntry { shape: shape, mapScale: mapScale, ); - final anchorOffset = CoordinateSystem.instance.virtualOffsetToWorld( + final anchorOffset = CoordinateSystem.virtualToWorld( storedAbilityAnchor(ability: shape, mapScale: mapScale), ); return PlacedAbility( @@ -211,15 +211,14 @@ class _FromStart extends _Placement { rotation = 0; } - final coordinates = CoordinateSystem.instance; var nearEdge = 0.0; var length = 0.0; if (shape is SquareAbility) { - nearEdge = - coordinates.virtualLengthToWorld(shape.distanceBetweenAOE * mapScale); + nearEdge = CoordinateSystem.virtualLengthInWorld( + shape.distanceBetweenAOE * mapScale); if (shape is ResizableSquareAbility && end != null) { - length = - (end - start).distance / coordinates.virtualLengthToWorld(mapScale); + length = (end - start).distance / + CoordinateSystem.virtualLengthInWorld(mapScale); } } return _Geometry( @@ -270,7 +269,7 @@ class _BarrierMesh extends _Placement { ]; final rotation = _rotationToward(arms.first) - slotOffset(DeadlockBarrierMeshArm.topRight); - final worldPerArmMeter = CoordinateSystem.instance.virtualLengthToWorld( + final worldPerArmMeter = CoordinateSystem.virtualLengthInWorld( deadlockBarrierMeshArmLengthVirtual(1, mapScale), ); // A blocked arm has no deployer; it shows at the shortest Icarus allows. diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index a91adaa64..9cf2a1ae0 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -11,6 +11,7 @@ import 'package:icarus/replay/replay_agents.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_weapons.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; /// One moment of a replay as Icarus draws it: the same placed widgets a page /// holds. The viewer paints these, and capturing the moment saves them. @@ -65,6 +66,10 @@ class ReplayFrameBuilder { final ReplayDocument document; final ReplayMapProjection projection; + /// The map's attack-side SVG height model, once loaded. Until then cones + /// stand on the level they pick by themselves. + SvgHeightVisibility? heightModel; + /// Cone length the editor allows at most; walls cut it down. static const _coneLength = ViewConeUtility.maxLength; @@ -105,8 +110,15 @@ class ReplayFrameBuilder { return alive; } - ReplayPlayerState playerState(ReplayPlayer player, int timeMs) { + /// [player] at [timeMs]. Their pose may be taken at [poseTimeMs] instead, + /// a moment earlier, when playback moves players a few at a time. + ReplayPlayerState playerState( + ReplayPlayer player, + int timeMs, { + int? poseTimeMs, + }) { final round = document.roundAt(timeMs); + final poseTime = poseTimeMs ?? timeMs; final roundStart = round?.startMs ?? 0; final alive = isAlive(player, timeMs, round); final track = document.movement[player.subject]; @@ -114,8 +126,8 @@ class ReplayFrameBuilder { if (track != null) { if (alive) { // A quiet stream means standing still, not vanishing. - pose = track.poseAt(timeMs) ?? - track.heldPose(timeMs, notBeforeMs: roundStart); + pose = track.poseAt(poseTime) ?? + track.heldPose(poseTime, notBeforeMs: roundStart); } else { // Dead this round: keep where they fell. final death = _lastDeathBefore(player.subject, timeMs); @@ -144,15 +156,28 @@ class ReplayFrameBuilder { return last; } + /// The level the player stands on, when it is not the one the cone picks. + double? _visionElevation(ReplayPose pose) { + final model = heightModel; + if (model == null) return null; + final p = pose.position; + return projection.visionElevationFor(model, p.x, p.y, p.z); + } + ReplayPlayer? ownerOf(ReplayUtility utility, ReplayAbilityEntry entry) => document.playerBySubject(utility.owner) ?? _soleCasterByAgent[entry.agent]; - ReplayFrame frameAt(int timeMs, {required ReplayTeam perspective}) { + /// The moment at [timeMs]. [poseTimeOf] may give a player's pose an + /// earlier time; see [ReplayPlayerState] and `ReplayPlayback.frame`. + ReplayFrame frameAt( + int timeMs, { + required ReplayTeam perspective, + int Function(ReplayPlayer player)? poseTimeOf, + }) { final round = document.roundAt(timeMs); final isAttack = (round?.attackingTeam ?? ReplayTeam.red) == perspective; - final coordinates = CoordinateSystem.instance; - final agentAnchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); + final agentAnchor = CoordinateSystem.virtualToWorld(storedAgentAnchor); final agents = []; for (final player in document.players) { @@ -160,7 +185,11 @@ class ReplayFrameBuilder { final team = player.team; // Without an agent or a team there is nothing honest to draw. if (type == null || team == null) continue; - final state = playerState(player, timeMs); + final state = playerState( + player, + timeMs, + poseTimeMs: poseTimeOf?.call(player), + ); final pose = state.pose; if (pose == null) continue; final position = @@ -168,7 +197,8 @@ class ReplayFrameBuilder { final isAlly = team == perspective; final id = 'replay-player-${player.subject}'; // What they carried out of the buy phase. - final weapon = replayWeaponType(round?.economyFor(player.subject)?.weapon); + final weapon = + replayWeaponType(round?.economyFor(player.subject)?.weapon); agents.add( state.alive ? PlacedViewConeAgent( @@ -178,6 +208,7 @@ class ReplayFrameBuilder { presetType: UtilityType.viewCone180, rotation: projection.rotationForYaw(pose.yaw), length: _coneLength, + visionElevation: _visionElevation(pose), isAlly: isAlly, weapon: weapon, ) @@ -228,7 +259,7 @@ class ReplayFrameBuilder { position: Offset.zero, ); spike.position = projection.toWorld(plantedAt.x, plantedAt.y) - - coordinates.virtualOffsetToWorld( + CoordinateSystem.virtualToWorld( storedUtilityAnchor( utility: spike, mapScale: Maps.mapScale[projection.map]!, diff --git a/lib/replay/replay_map_projection.dart b/lib/replay/replay_map_projection.dart index 2dba793d0..de36e5e00 100644 --- a/lib/replay/replay_map_projection.dart +++ b/lib/replay/replay_map_projection.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:icarus/const/map_artwork_registration.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; /// Places Valorant game-world positions on Icarus's maps. /// @@ -106,6 +107,54 @@ class ReplayMapProjection { double worldLength(double centimetres) => centimetres / 100 * _native.scale * _scale; + /// Replay z is the capsule centre. Across 373k replay samples on four maps + /// it sits a median 1.003 m above the height model's ground. + static const _capsuleCentreAboveFloorMeters = 1.003; + + /// A level more than this far above the feet is overhead, not underfoot. + /// Crouching lowers the capsule centre by less; the stacked floors a + /// player can stand under are 2.5 m or more apart. + static const _maxLevelAboveFeetMeters = 0.6; + + /// Levels this close count as the cone's own choice. Replay z scatters + /// about a quarter metre around the floor, too little to tell such levels + /// apart, and many maps carry a "Platform" support within centimetres of + /// the ground beneath it. + static const _sameLevelMeters = 0.5; + + /// Game-world cm (Unreal x, y, z) -> the `visionElevation` a + /// PlacedViewConeAgent standing there should save: the eye elevation in cm + /// of the level the player is on, or null where the cone's own choice + /// already stands on that level, so ordinary floors save as the editor + /// saves them. + /// + /// [attackModel] is the map's attack-side SVG height model. Levels are the + /// ones the editor's elevation menu lists where the cone is drawn from: the + /// ground and every support. The nearest to the player's feet wins, + /// ignoring levels overhead. A player above every level (on a rope, or on + /// something the model lacks) gets null, since no saved value could say + /// more than the cone's own choice. + double? visionElevationFor( + SvgHeightVisibility attackModel, double x, double y, double z) { + final levels = _ReplayLevels.at(attackModel, attackSvgPoint(x, y)); + if (levels == null) return null; + final feet = z / 100 - _capsuleCentreAboveFloorMeters; + double? standing; + for (final surface in levels.surfaces) { + if (surface > feet + _maxLevelAboveFeetMeters) continue; + if (standing == null || + (surface - feet).abs() < (standing - feet).abs()) { + standing = surface; + } + } + if (standing == null) return null; + final automatic = levels.automaticSurface; + if (automatic != null && (standing - automatic).abs() < _sameLevelMeters) { + return null; + } + return (standing + attackModel.defaultCameraHeightMeters) * 100; + } + // Native metres -> attack SVG, `nativeToAttackSvg` in // E:\IcarusWorldAudit\2026-09-06\tactical-alignment-sides-v1\.json. // Each composes Riot's minimap UIData transform with the minimap image's @@ -141,6 +190,40 @@ class ReplayMapProjection { }; } +/// The levels a cone could stand on near one point of a height model, and +/// which one it stands on by itself. Finding them scans every wall and +/// support, so each model keeps them per quarter SVG unit (about 6 cm): +/// playback asks again for the same few spots every frame. +class _ReplayLevels { + _ReplayLevels(this.surfaces, this.automaticSurface); + + final List surfaces; + final double? automaticSurface; + + static const _cellsPerSvgUnit = 4; + static final _cache = Expando>(); + + static _ReplayLevels? at(SvgHeightVisibility model, Offset point) { + final cells = _cache[model] ??= {}; + final key = (point.dx * _cellsPerSvgUnit).floor() * 65536 + + (point.dy * _cellsPerSvgUnit).floor(); + return cells.putIfAbsent(key, () => _measure(model, point)); + } + + static _ReplayLevels? _measure(SvgHeightVisibility model, Offset point) { + // The cone stands at this point, pushed out of any wall it is in. + final origin = model.standablePointNear(point); + if (origin == null) return null; + final ground = model.ground?.heightAt(origin); + final automatic = model.automaticSupportAt(origin); + return _ReplayLevels([ + if (ground != null) ground, + for (final support in model.supportsAt(origin)) + if (support.surfaceElevationAt(origin) case final surface?) surface, + ], automatic == null ? ground : automatic.surfaceElevationAt(origin)); + } +} + /// Row-major 2x3 matrix: (x, y) -> (a x + b y + c, d x + e y + f). class _Affine { const _Affine(this.a, this.b, this.c, this.d, this.e, this.f); diff --git a/lib/replay/replay_playback.dart b/lib/replay/replay_playback.dart index 530184d62..b1625ff6a 100644 --- a/lib/replay/replay_playback.dart +++ b/lib/replay/replay_playback.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; /// Playback state for one open replay: the clock, the speed, and whose eyes /// the match is seen through. The view drives [advance] from a ticker. @@ -40,7 +41,11 @@ class ReplayPlayback extends ChangeNotifier { int get durationMs => document.match.durationMs; ReplayRound? get round => document.roundAt(_timeMs); - ReplayFrame? _frame; + /// The moment on screen. While playing, each new frame moves only the + /// [_movesPerFrame] players whose shown pose is oldest, plus any older + /// than [_maxPoseAgeMs]: a moved player's view cone is cut against the + /// walls again, and all ten in one frame is more than a 165 Hz frame + /// holds. Paused, every player is exact; so is every capture. ReplayFrame get frame { final cached = _frame; if (cached != null && @@ -49,11 +54,55 @@ class ReplayPlayback extends ChangeNotifier { return cached; } _framePerspective = _perspective; - return _frame = frames.frameAt(_timeMs, perspective: _perspective); + if (_playing) { + _staggerPoses(); + } else { + _poseTimes.clear(); + } + return _frame = frames.frameAt( + _timeMs, + perspective: _perspective, + poseTimeOf: (player) => _poseTimes[player.subject] ?? _timeMs, + ); } + ReplayFrame? _frame; ReplayTeam? _framePerspective; + static const _movesPerFrame = 3; + static const _maxPoseAgeMs = 66; + + /// When each player's shown pose was taken, while playing. + final _poseTimes = {}; + + void _staggerPoses() { + final players = document.players; + if (_poseTimes.length != players.length) { + for (final player in players) { + _poseTimes[player.subject] = _timeMs; + } + return; + } + final oldestFirst = [...players]..sort( + (a, b) => _poseTimes[a.subject]!.compareTo(_poseTimes[b.subject]!), + ); + for (var i = 0; i < oldestFirst.length; i++) { + final subject = oldestFirst[i].subject; + final age = _timeMs - _poseTimes[subject]!; + if (i < _movesPerFrame || age > _maxPoseAgeMs || age < 0) { + _poseTimes[subject] = _timeMs; + } + } + } + + /// The map's height model, for standing each cone on the right level; + /// it loads after the replay opens. + set heightModel(SvgHeightVisibility? model) { + frames.heightModel = model; + _frame = null; + notifyListeners(); + } + void play() { if (_playing) return; if (_timeMs >= durationMs) _timeMs = round?.playStartMs ?? 0; @@ -64,6 +113,8 @@ class ReplayPlayback extends ChangeNotifier { void pause() { if (!_playing) return; _playing = false; + // Show every player exactly where they are. + _frame = null; notifyListeners(); } @@ -84,6 +135,7 @@ class ReplayPlayback extends ChangeNotifier { void seek(int timeMs) { final clamped = timeMs.clamp(0, durationMs); _carryMs = 0; + _poseTimes.clear(); if (clamped == _timeMs) return; _timeMs = clamped; notifyListeners(); diff --git a/lib/replay_view.dart b/lib/replay_view.dart index ff49795ea..833abdc1d 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -10,6 +10,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_decoder.dart'; import 'package:icarus/replay/replay_document.dart'; @@ -17,6 +18,7 @@ import 'package:icarus/replay/replay_files.dart'; import 'package:icarus/replay/replay_loader.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; import 'package:icarus/widgets/replay/replay_canvas.dart'; import 'package:icarus/widgets/replay/replay_dock.dart'; import 'package:icarus/widgets/replay/replay_match_card.dart'; @@ -56,6 +58,8 @@ class _ReplayViewState extends ConsumerState Timer? _progressTimer; ReplayPlayback? _playback; + ProviderSubscription>? _heightRuntime; + SvgHeightVisibility? _heightModel; ReplayDecodeException? _error; Duration _lastTick = Duration.zero; @@ -68,6 +72,15 @@ class _ReplayViewState extends ConsumerState file: widget.file, probe: widget.probe, ); + final map = ReplayMapProjection.forMapPath(widget.probe.mapPath)?.map; + if (map != null) { + // Held for the screen's life: the provider frees the model otherwise. + _heightRuntime = ref.listenManual( + svgHeightRuntimeProvider(map), + (_, runtime) => _applyHeightModel(runtime.valueOrNull), + fireImmediately: true, + ); + } // Progress lives in native memory; repaint the bar while decoding. _progressTimer = Timer.periodic( const Duration(milliseconds: 100), @@ -94,6 +107,7 @@ class _ReplayViewState extends ConsumerState projection: projection, perspective: perspective, )..addListener(_syncMapSide); + if (_heightModel != null) playback.heightModel = _heightModel; setState(() => _playback = playback); claimEditorState(); _ticker.start(); @@ -109,6 +123,15 @@ class _ReplayViewState extends ConsumerState } } + /// Cones stand on the level the replay says each player is on, once the + /// map's height model is in. + void _applyHeightModel(SvgHeightRuntime? runtime) { + final model = runtime?.model(true); + if (model == null || identical(model, _heightModel)) return; + _heightModel = model; + _playback?.heightModel = model; + } + void _fail(ReplayDecodeException error) { if (mounted) setState(() => _error = error); } @@ -169,6 +192,7 @@ class _ReplayViewState extends ConsumerState @override void dispose() { _progressTimer?.cancel(); + _heightRuntime?.close(); _loader.cancel(); _ticker.dispose(); _playback diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index c496a380e..069a1f522 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -59,8 +59,6 @@ List> still(int from, int to, {double x = 0}) => [ ]; void main() { - setUpAll(() => CoordinateSystem(playAreaSize: const Size(1778, 1000))); - final projection = ReplayMapProjection.forMapPath( '/Game/Maps/Juliett/Juliett', )!; @@ -121,8 +119,8 @@ void main() { }, ); final frames = builder(doc); - expect(frames.isAlive(player(doc, 'a'), 61000, doc.roundAt(61000)), - isTrue); + expect( + frames.isAlive(player(doc, 'a'), 61000, doc.roundAt(61000)), isTrue); // And last round's reading is not shown as this round's health. expect(frames.playerState(player(doc, 'a'), 61000).health, isNull); }); @@ -209,14 +207,27 @@ void main() { utility: [smoke], ); expect( - builder(twoOmens) - .frameAt(2000, perspective: ReplayTeam.red) - .abilities, + builder(twoOmens).frameAt(2000, perspective: ReplayTeam.red).abilities, isEmpty, ); }); }); + test('size conversions need no laid-out canvas, and match the canvas', () { + for (final height in [700.0, 1440.0]) { + final canvas = CoordinateSystem( + playAreaSize: Size(height * 16 / 9, height), + ); + const offset = Offset(17.5, 17.5); + final viaCanvas = canvas.virtualOffsetToWorld(offset); + final static = CoordinateSystem.virtualToWorld(offset); + expect(static.dx, closeTo(viaCanvas.dx, 1e-9)); + expect(static.dy, closeTo(viaCanvas.dy, 1e-9)); + expect(CoordinateSystem.virtualLengthInWorld(30), + closeTo(canvas.virtualLengthToWorld(30), 1e-9)); + } + }); + group('playback', () { ReplayPlayback playback() => ReplayPlayback( document: document(), @@ -243,6 +254,48 @@ void main() { expect(p.timeMs, 0); }); + test('playing moves a few players a frame; pausing shows all exactly', () { + final subjects = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5']; + final doc = document( + players: [ + for (final subject in subjects) + {'subject': subject, 'agentId': jett, 'team': 'Red'}, + ], + movement: { + // Everyone walks along x, 1 cm a millisecond. + for (final subject in subjects) + subject: [ + for (var t = 0; t <= 60000; t += 50) + [t, t.toDouble(), 0, 100, 0, 0] + ], + }, + ); + final playback = ReplayPlayback( + document: doc, + projection: projection, + perspective: ReplayTeam.red, + )..play(); + Map positions() => { + for (final agent in playback.frame.agents) agent.id: agent.position, + }; + final before = positions(); + playback.advance(const Duration(milliseconds: 10)); + final after = positions(); + final moved = subjects + .where((s) => before['replay-player-$s'] != after['replay-player-$s']) + .length; + expect(moved, 3); + + playback.pause(); + final exact = playback.frames + .frameAt(playback.timeMs, perspective: ReplayTeam.red) + .agents; + expect( + {for (final agent in playback.frame.agents) agent.id: agent.position}, + {for (final agent in exact) agent.id: agent.position}, + ); + }); + test('playing stops at the end', () { final p = playback(); p diff --git a/test/replay_map_projection_test.dart b/test/replay_map_projection_test.dart index 88d8bf669..ac31d0386 100644 --- a/test/replay_map_projection_test.dart +++ b/test/replay_map_projection_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -5,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; const _degrees = math.pi / 180; @@ -225,4 +228,100 @@ void main() { .abs(), lessThan(1e-9)); }); + + // Real replay poses (Unreal cm). Feet are z - 1.003 m; the cone's eye is the + // level's surface plus the model's 1.75 m standing eye. + group('visionElevationFor', () { + final models = <(MapValue, String), SvgHeightVisibility>{}; + SvgHeightVisibility model(MapValue map, {String side = 'attack'}) => + models[( + map, + side + )] ??= SvgHeightVisibility.fromJson(jsonDecode(utf8.decode(gzip.decode( + File('assets/maps/${Maps.mapNames[map]}_svg_height_$side.json.gz') + .readAsBytesSync()))) as Map); + double? elevation(MapValue map, double x, double y, double z) => + ReplayMapProjection.forMap(map).visionElevationFor(model(map), x, y, z); + + /// The surface the cone widget stands its eye on for a saved elevation. + double surfaceUnder(MapValue map, double x, double y, double? saved, + {String side = 'attack'}) { + final projection = ReplayMapProjection.forMap(map); + final heights = model(map, side: side); + final origin = heights.standablePointNear(side == 'attack' + ? projection.attackSvgPoint(x, y) + : projection.defenseSvgPoint(x, y))!; + final support = + heights.standingSupportAt(origin, savedEyeElevationCm: saved); + return support?.surfaceElevationAt(origin) ?? + heights.ground!.heightAt(origin)!; + } + + test('ordinary floors keep the editor default', () { + // Spawn corridors on Split, Summit and Sunset. + expect(elevation(MapValue.split, 1950, 472.1, 400.3), isNull); + expect(elevation(MapValue.summit, 764.47, 6200.72, 300.3), isNull); + expect(elevation(MapValue.sunset, -4621.59, 719.23, 300.3), isNull); + }); + + test('Split: under an automatic roof support, the floor', () { + // c8989335 at 1913.0 s: the default stands the eye on a support 37 m + // up; the player's feet are at 6.3 m on 6.5 m ground. + final (x, y, z) = (2548.0, -4509.0, 730.0); + expect(surfaceUnder(MapValue.split, x, y, null), greaterThan(37)); + final saved = elevation(MapValue.split, x, y, z); + expect(saved, closeTo(825, .5)); + expect(surfaceUnder(MapValue.split, x, y, saved), closeTo(6.5, .01)); + }); + + test('Lotus: under a 9.5 m platform, the floor beneath it', () { + // d3c0e7a2 at 411.8 s, feet 3.39 m. + final (x, y, z) = (8347.0, 4240.0, 439.0); + expect(surfaceUnder(MapValue.lotus, x, y, null), closeTo(9.49, .02)); + final saved = elevation(MapValue.lotus, x, y, z); + expect(saved, closeTo(496.4, .5)); + expect(surfaceUnder(MapValue.lotus, x, y, saved), closeTo(3.21, .01)); + }); + + test('Sunset: standing on the B medium planter', () { + // c8313344 at 202.5 s, feet 7.0 m over 4.0 m ground. + final (x, y, z) = (2619.0, -3652.0, 800.0); + expect(surfaceUnder(MapValue.sunset, x, y, null), closeTo(4.0, .01)); + final saved = elevation(MapValue.sunset, x, y, z); + expect(saved, closeTo(777.0, .5)); + expect(surfaceUnder(MapValue.sunset, x, y, saved), closeTo(6.02, .01)); + }); + + test('Split B heaven: on it keeps the default, beneath it is the floor', + () { + // b63bb117 at 88.0 s on heaven (9.25 m over 5.0 m B site floor). + final (x, y) = (-198.90, -5402.98); + expect(elevation(MapValue.split, x, y, 1054.22), isNull); + expect(surfaceUnder(MapValue.split, x, y, null), closeTo(9.25, .01)); + // The same spot at B site floor height (constructed z, feet 5.0 m). + final under = elevation(MapValue.split, x, y, 600); + expect(under, closeTo(675, .5)); + expect(surfaceUnder(MapValue.split, x, y, under), closeTo(5.0, .01)); + }); + + test('above every level the model has: the default, not a guess', () { + // b63bb117 at 87.8 s: feet 8.6 m where the model's levels stop at 5 m. + expect(elevation(MapValue.split, -270.01, -5402.82, 965.14), isNull); + }); + + test('the defense side resolves the same level', () { + for (final (map, x, y, z) in const [ + (MapValue.split, 2548.0, -4509.0, 730.0), + (MapValue.lotus, 8347.0, 4240.0, 439.0), + (MapValue.sunset, 2619.0, -3652.0, 800.0), + (MapValue.split, -198.90, -5402.98, 600.0), + ]) { + final saved = elevation(map, x, y, z); + expect(saved, isNotNull); + expect(surfaceUnder(map, x, y, saved, side: 'defense'), + closeTo(surfaceUnder(map, x, y, saved), .01), + reason: '$map ($x, $y, $z)'); + } + }); + }); } From ee0751f62b16fb3d3635fc1d50b9468e0591b53e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 01:35:52 -0400 Subject: [PATCH 07/18] Drop an unneeded import in the capture test Co-Authored-By: Claude Opus 5.5 (1M context) --- test/replay_capture_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/replay_capture_test.dart b/test/replay_capture_test.dart index d954213ba..43c202b61 100644 --- a/test/replay_capture_test.dart +++ b/test/replay_capture_test.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; From 0eb3383663aa379163858a56ffc7a923c70c40ce Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 02:15:52 -0400 Subject: [PATCH 08/18] Address review: bounded decoding, atomic control handle, release notices Native: - Cap file size, inflated data, schema groups and slots, retained records and output so a crafted replay is refused as corrupt instead of exhausting memory; the schema is checked before vrfkit allocates it. - Progress and cancel go through a native control handle, so Dart never touches the atomics directly. - quality.loss reports vrfkit's real loss counters. - A repeated round reset no longer panics the decode; a dormant actor that wakes keeps one lifecycle; same-millisecond vitals follow wire order. - Ship icarus_replay_NOTICE.md and the license beside the library. - Pin Rust 1.96.1 and bootstrap it in CI and release builds; the cargo CI job gets a read-only token and no persisted credentials. Dart: - Parse decoded replays off the UI isolate. - A probe player with no agent no longer fails the whole replay. - Capture refuses non-finite rotation and lengths on every widget. - The spike leaves the map when defused or detonated; players in Iso's off-map duel arena are not drawn or captured. - Importing a different file of the same name replaces the kept copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 14 +- .github/workflows/release-desktop.yml | 10 +- .github/workflows/release-store.yml | 10 +- docs/replay-format.md | 47 ++- lib/replay/replay_capture.dart | 28 +- lib/replay/replay_decoder.dart | 4 +- lib/replay/replay_decoder_io.dart | 59 ++-- lib/replay/replay_files.dart | 12 +- lib/replay/replay_frame.dart | 25 +- lib/replay/replay_loader.dart | 11 +- native/replay/CMakeLists.txt | 4 + native/replay/build_macos.sh | 20 +- native/replay/include/icarus_replay.h | 38 ++- native/replay/rust-toolchain.toml | 8 + native/replay/src/analysis/casts.rs | 77 ++--- native/replay/src/analysis/mod.rs | 31 +- native/replay/src/analysis/rounds.rs | 174 +++++++++-- native/replay/src/analysis/utility.rs | 184 ++++++++---- native/replay/src/analysis/vitals.rs | 240 +++++++++++---- native/replay/src/bin/icarus-replay-dump.rs | 12 +- native/replay/src/collect.rs | 79 ++++- native/replay/src/decode.rs | 69 +++-- native/replay/src/document.rs | 31 ++ native/replay/src/ffi.rs | 122 ++++++-- native/replay/src/header.rs | 3 +- native/replay/src/lib.rs | 51 +++- native/replay/src/limits.rs | 310 ++++++++++++++++++++ native/replay/src/oodle.rs | 20 ++ native/replay/src/vendor/frame.rs | 204 +++++++++++++ native/replay/src/vendor/mod.rs | 4 + native/replay/src/vendor/pass.rs | 30 +- native/replay/tests/corpus.rs | 12 +- test/replay_capture_test.dart | 23 ++ test/replay_files_test.dart | 11 + test/replay_frame_test.dart | 40 ++- windows/CMakeLists.txt | 4 + 36 files changed, 1693 insertions(+), 328 deletions(-) create mode 100644 native/replay/rust-toolchain.toml create mode 100644 native/replay/src/limits.rs create mode 100644 native/replay/src/vendor/frame.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5b2ca66b..4ae18cdf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,22 @@ on: jobs: native-replay: runs-on: ubuntu-latest + # Cargo build scripts run here: no write token, none left in .git. + permissions: + contents: read defaults: run: working-directory: native/replay steps: - uses: actions/checkout@v4 + with: + persist-credentials: false + # The version in native/replay/rust-toolchain.toml. - name: Install Rust - run: | - rustup toolchain install stable --profile minimal --component clippy - rustup default stable + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt - name: Cache Cargo uses: actions/cache@v4 @@ -27,7 +33,7 @@ jobs: ~/.cargo/registry ~/.cargo/git native/replay/target - key: ${{ runner.os }}-cargo-replay-${{ hashFiles('native/replay/Cargo.lock') }} + key: ${{ runner.os }}-cargo-replay-${{ hashFiles('native/replay/Cargo.lock', 'native/replay/rust-toolchain.toml') }} restore-keys: | ${{ runner.os }}-cargo-replay- diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ce010dcc9..d5f37870c 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -82,12 +82,12 @@ jobs: shell: pwsh run: fvm install - # windows/CMakeLists.txt builds the replay decoder (native/replay) with cargo. + # windows/CMakeLists.txt builds the replay decoder (native/replay) with + # cargo, on the version in native/replay/rust-toolchain.toml. - name: Install Rust - shell: pwsh - run: | - rustup toolchain install stable --profile minimal - rustup default stable + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt - name: Build Windows Release Binaries shell: pwsh diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 29f0d803e..427ed6bb7 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -51,12 +51,12 @@ jobs: shell: pwsh run: powershell -ExecutionPolicy Bypass -File scripts/bump_version.ps1 -Bump ${{ inputs.version_bump }} - # windows/CMakeLists.txt builds the replay decoder (native/replay) with cargo. + # windows/CMakeLists.txt builds the replay decoder (native/replay) with + # cargo, on the version in native/replay/rust-toolchain.toml. - name: Install Rust - shell: pwsh - run: | - rustup toolchain install stable --profile minimal - rustup default stable + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt - name: Build Store Package shell: pwsh diff --git a/docs/replay-format.md b/docs/replay-format.md index fa7aab316..00927462a 100644 --- a/docs/replay-format.md +++ b/docs/replay-format.md @@ -119,7 +119,17 @@ first byte. }, "quality": { "transformVerified": true, - "decodeErrors": 0, + "decodeErrors": 0, // typed field decodes that failed + "loss": { // see "Quality" + "malformedPackets": 0, + "bunchHeaderFailures": 0, + "lostContentBlocks": 0, + "rejectedPartials": 0, + "unfinishedPartials": 0, + "refusedBunches": 0, + "unopenedChannelBunches": 0, + "packageMapExportBunches": 0 + }, "warnings": ["…"] // see "Warnings" } } @@ -203,13 +213,37 @@ agents across our 7 replays agrees with the item `classPath` where one is known. `classPath` is the ability item, absent where no charge spend ties a cast to it (always for ultimates). +## Quality + +`transformVerified` is the transform guard's verdict; a decode that fails it +is refused, so a document always says `true`. `decodeErrors` counts field +values vrfkit could not type (its `overlay.decoded_err`). + +`loss` is what the replication stream lost before any field was read: the +counters vrfkit's own `validate` judges a replay by. No two count the same +thing, and a payload vrfkit keeps whole when it cannot decode it is not lost. + +| Counter | What it counts | vrfkit `NetStats` | +|---|---|---| +| `malformedPackets` | packets abandoned part-read | `malformed_packets` | +| `bunchHeaderFailures` | bunches whose header did not read | `bunch_header_failures` | +| `lostContentBlocks` | actor content blocks whose payload reached no field or RPC | `lost_content_blocks()` | +| `rejectedPartials` | partial-bunch fragments refused during reassembly | `partial_errors` | +| `unfinishedPartials` | partial bunches still incomplete at the end | `unfinished_partials` | +| `refusedBunches` | bunches refused at a channel-state limit | `channel_state_limit_failures` | +| `unopenedChannelBunches` | bunches for a channel with no open actor, dropped | `bunches_on_unopened_channel` | +| `packageMapExportBunches` | bunches whose content after a package-map export is not read | `package_map_exports` | + +All are 0 on every replay in our corpus. Each non-zero one is also a +warning. + ## Warnings `quality.warnings` names what the decoder dropped, could not attribute, or -chose between, one line each: decode failures by kind, deaths or utility it -could not attribute, assists it could not separate, rounds the replay stops -inside, and game phases sent twice (where `combatStartMs` takes the first). -A clean replay has none. +chose between, one line each: losses and decode failures by kind, deaths or +utility it could not attribute, assists it could not separate, rounds the +replay stops inside, and game phases sent twice (where `combatStartMs` takes +the first). A clean replay has none. ## Probe @@ -245,3 +279,6 @@ Codes: `notAReplay`, `unsupportedBuild`, `unsupportedCompression`, `transformCheckFailed`, `corrupt`, `io`, `cancelled`. `build` is present whenever the header was read. `transformCheckFailed` means the payloads decoded into noise: the build's registered transform does not fit the file. +`corrupt` also covers a file that would need more than the decoder's caps +(`native/replay/src/limits.rs`): a file over 512 MB, a schema or record set +several times larger than a real match's. diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart index e53462632..dee582db6 100644 --- a/lib/replay/replay_capture.dart +++ b/lib/replay/replay_capture.dart @@ -85,16 +85,26 @@ class ReplayCapture { } static void _checkFinite(ReplayFrame frame) { - bool finite(double value) => value.isFinite; for (final widget in frame.widgets) { - final rotation = switch (widget) { - PlacedViewConeAgent(:final rotation) => rotation, - PlacedAbility(:final rotation) => rotation, - _ => 0.0, - }; - if (!finite(widget.position.dx) || - !finite(widget.position.dy) || - !finite(rotation)) { + final geometry = [ + widget.position.dx, + widget.position.dy, + ...switch (widget) { + PlacedViewConeAgent(:final rotation, :final length) => [ + rotation, + length, + ], + PlacedAbility( + :final rotation, + :final length, + :final armLengthsMeters + ) => + [rotation, length, ...armLengthsMeters], + PlacedUtility(:final rotation, :final length) => [rotation, length], + _ => const [], + }, + ]; + if (geometry.any((value) => !value.isFinite)) { throw StateError('Capture refused: ${widget.id} is not on the map.'); } } diff --git a/lib/replay/replay_decoder.dart b/lib/replay/replay_decoder.dart index 03ce0476a..5ce0a7fc6 100644 --- a/lib/replay/replay_decoder.dart +++ b/lib/replay/replay_decoder.dart @@ -64,8 +64,10 @@ class ReplayProbe { supported: json['supported'] as bool? ?? false, decoderVersion: json['decoderVersion'] as String, agentIds: [ + // A player the header names no agent for is left out, not fatal. for (final player in (json['players'] as List? ?? const [])) - ((player as Map)['agentId'] as String).toLowerCase(), + if ((player as Map)['agentId'] case final String agentId) + agentId.toLowerCase(), ], ); } diff --git a/lib/replay/replay_decoder_io.dart b/lib/replay/replay_decoder_io.dart index 43cd81845..1375a693f 100644 --- a/lib/replay/replay_decoder_io.dart +++ b/lib/replay/replay_decoder_io.dart @@ -35,25 +35,48 @@ final class _Buffer extends Struct { external int isError; } +/// The library's `IcarusReplayControl`: a decode's progress and cancel +/// request, read and written atomically on the native side. +final class _Control extends Opaque {} + typedef _ProbeC = _Buffer Function(Pointer); -typedef _DecodeC = _Buffer Function( - Pointer, Pointer, Pointer); -typedef _DecodeD = _Buffer Function( - Pointer, Pointer, Pointer); +typedef _DecodeC = _Buffer Function(Pointer, Pointer<_Control>); typedef _FreeC = Void Function(_Buffer); typedef _FreeD = void Function(_Buffer); +typedef _ControlNewC = Pointer<_Control> Function(); +typedef _ControlProgressC = Uint32 Function(Pointer<_Control>); +typedef _ControlProgressD = int Function(Pointer<_Control>); +typedef _ControlC = Void Function(Pointer<_Control>); +typedef _ControlD = void Function(Pointer<_Control>); class _Library { _Library(DynamicLibrary library) : probe = library.lookupFunction<_ProbeC, _ProbeC>('icarus_replay_probe'), - decode = library.lookupFunction<_DecodeC, _DecodeD>( + decode = library.lookupFunction<_DecodeC, _DecodeC>( 'icarus_replay_decode', ), - free = library.lookupFunction<_FreeC, _FreeD>('icarus_replay_free'); + free = library.lookupFunction<_FreeC, _FreeD>('icarus_replay_free'), + controlNew = library.lookupFunction<_ControlNewC, _ControlNewC>( + 'icarus_replay_control_new', + ), + controlProgress = + library.lookupFunction<_ControlProgressC, _ControlProgressD>( + 'icarus_replay_control_progress', + ), + controlCancel = library.lookupFunction<_ControlC, _ControlD>( + 'icarus_replay_control_cancel', + ), + controlFree = library.lookupFunction<_ControlC, _ControlD>( + 'icarus_replay_control_free', + ); final _Buffer Function(Pointer) probe; - final _DecodeD decode; + final _Buffer Function(Pointer, Pointer<_Control>) decode; final _FreeD free; + final Pointer<_Control> Function() controlNew; + final _ControlProgressD controlProgress; + final _ControlD controlCancel; + final _ControlD controlFree; static _Library? _instance; @@ -106,10 +129,10 @@ ReplayDecodeJob decodeReplay(String path) => _IoDecodeJob(path); class _IoDecodeJob implements ReplayDecodeJob { _IoDecodeJob(String path) { - // Both cells are written across isolates, so they live in native memory - // and are freed only after the decoding isolate has finished with them. - final progressAddress = _progress.address; - final cancelAddress = _cancel.address; + // The control is shared with the decoding isolate by address, so it is + // freed only after that isolate has finished with it. Both sides reach + // it only through the library, which accesses it atomically. + final controlAddress = _control.address; result = Isolate.run(() { final library = _Library.instance; final nativePath = path.toNativeUtf8(); @@ -117,32 +140,30 @@ class _IoDecodeJob implements ReplayDecodeJob { return library.take( library.decode( nativePath, - Pointer.fromAddress(progressAddress), - Pointer.fromAddress(cancelAddress), + Pointer<_Control>.fromAddress(controlAddress), ), ); } finally { malloc.free(nativePath); } }).whenComplete(() { - calloc.free(_progress); - calloc.free(_cancel); _done = true; + _library.controlFree(_control); }); } - final Pointer _progress = calloc(); - final Pointer _cancel = calloc(); + final _Library _library = _Library.instance; + late final Pointer<_Control> _control = _library.controlNew(); bool _done = false; @override late final Future result; @override - double get progress => _done ? 1 : _progress.value / 10000; + double get progress => _done ? 1 : _library.controlProgress(_control) / 10000; @override void cancel() { - if (!_done) _cancel.value = 1; + if (!_done) _library.controlCancel(_control); } } diff --git a/lib/replay/replay_files.dart b/lib/replay/replay_files.dart index 676e9267e..a77cbabd7 100644 --- a/lib/replay/replay_files.dart +++ b/lib/replay/replay_files.dart @@ -150,17 +150,21 @@ class ReplayFiles { } /// Copies a replay into Icarus's folder, keeping its match-id name, and - /// returns the copy. A file already there is returned as is. + /// returns the copy. The same file already there is returned as is; a + /// different one of the same name (a partial download) is replaced. Future import(String sourcePath) async { final directory = await icarusReplaysDirectory(); final destination = File(p.join(directory.path, p.basename(sourcePath))); - if (!p.equals(destination.path, sourcePath) && - !await destination.exists()) { + final source = File(sourcePath); + final alreadyKept = await destination.exists() && + await destination.length() == await source.length(); + if (!p.equals(destination.path, sourcePath) && !alreadyKept) { // Copy to a temporary name first so a half-copied file is never // listed as a replay. final partial = _partial(destination); try { - await File(sourcePath).copy(partial.path); + await source.copy(partial.path); + if (await destination.exists()) await destination.delete(); await partial.rename(destination.path); } finally { if (await partial.exists()) await partial.delete(); diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index 9cf2a1ae0..d457d2e8a 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:icarus/const/agents.dart'; @@ -12,6 +13,7 @@ import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_weapons.dart'; import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; /// One moment of a replay as Icarus draws it: the same placed widgets a page /// holds. The viewer paints these, and capturing the moment saves them. @@ -156,6 +158,20 @@ class ReplayFrameBuilder { return last; } + /// The spike stays planted until it is defused, it detonates, or the + /// round ends. + static int _spikeGoneMs(ReplayRound round) => [ + round.endMs, + if (round.defuse case final defuse?) defuse.timeMs, + if (round.detonateMs case final detonateMs?) detonateMs, + ].reduce(math.min); + + static bool _onCanvas(Offset world) => + world.dx >= 0 && + world.dy >= 0 && + world.dx <= SvgHeightMapTransform.worldWidth && + world.dy <= SvgHeightMapTransform.worldHeight; + /// The level the player stands on, when it is not the one the cone picks. double? _visionElevation(ReplayPose pose) { final model = heightModel; @@ -192,8 +208,11 @@ class ReplayFrameBuilder { ); final pose = state.pose; if (pose == null) continue; - final position = - projection.toWorld(pose.position.x, pose.position.y) - agentAnchor; + final standing = projection.toWorld(pose.position.x, pose.position.y); + // Iso's Kill Contract duels in an arena off the map; while there, the + // player has no place on it (the roster still lists them). + if (!_onCanvas(standing)) continue; + final position = standing - agentAnchor; final isAlly = team == perspective; final id = 'replay-player-${player.subject}'; // What they carried out of the buy phase. @@ -252,7 +271,7 @@ class ReplayFrameBuilder { plant != null && plantedAt != null && timeMs >= plant.timeMs && - timeMs < round.endMs) { + timeMs < _spikeGoneMs(round)) { final spike = PlacedUtility( id: 'replay-spike-${round.index}', type: UtilityType.spike, diff --git a/lib/replay/replay_loader.dart b/lib/replay/replay_loader.dart index 2eb4208d5..dcbe82867 100644 --- a/lib/replay/replay_loader.dart +++ b/lib/replay/replay_loader.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:icarus/replay/replay_decoder.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_files.dart'; @@ -37,7 +38,8 @@ class ReplayLoader { final job = _job = decodeReplay(file.path); final bytes = await job.result; _throwIfCancelled(); - final document = ReplayDocument.fromBytes(bytes); + final document = await _parse(bytes); + _throwIfCancelled(); try { await files.writeCache(file, probe.decoderVersion, bytes); } on Exception { @@ -46,12 +48,17 @@ class ReplayLoader { return document; } + /// Reading a whole match is too much work for a frame, so it happens on + /// another isolate; the screen keeps drawing meanwhile. + static Future _parse(Uint8List bytes) => + compute(ReplayDocument.fromBytes, bytes); + /// The cached document, or null when there is none or it can't be used: /// an unreadable or outdated cache is simply decoded again. Future _readCache() async { try { final bytes = await files.readCache(file, probe.decoderVersion); - return bytes == null ? null : ReplayDocument.fromBytes(bytes); + return bytes == null ? null : await _parse(bytes); } on Exception { return null; } diff --git a/native/replay/CMakeLists.txt b/native/replay/CMakeLists.txt index ba07bb1ae..d0cb5f335 100644 --- a/native/replay/CMakeLists.txt +++ b/native/replay/CMakeLists.txt @@ -15,6 +15,10 @@ else() set(ICARUS_REPLAY_LIBRARY "${ICARUS_REPLAY_TARGET_DIR}/release/libicarus_replay.so") endif() set(ICARUS_REPLAY_LIBRARY "${ICARUS_REPLAY_LIBRARY}" PARENT_SCOPE) +# Apache-2.0 section 4: vrfkit's notices and the license text travel with the +# library, so the runner installs both beside it. +set(ICARUS_REPLAY_NOTICE "${CMAKE_CURRENT_SOURCE_DIR}/NOTICE.md" PARENT_SCOPE) +set(ICARUS_REPLAY_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/../../LICENSE" PARENT_SCOPE) add_custom_target(icarus_replay ALL COMMAND "${CARGO}" build --release --locked --lib diff --git a/native/replay/build_macos.sh b/native/replay/build_macos.sh index c6fa497b5..51883a0bf 100644 --- a/native/replay/build_macos.sh +++ b/native/replay/build_macos.sh @@ -1,11 +1,14 @@ #!/bin/sh # Xcode build phase: builds libicarus_replay.dylib for every arch in $ARCHS -# (lipo'd into one universal dylib) into the app's Frameworks folder. -# Needs rustup with the aarch64-apple-darwin and x86_64-apple-darwin targets. +# (lipo'd into one universal dylib) into the app's Frameworks folder, and +# puts its notices in Resources. +# Needs rustup; the toolchain is native/replay/rust-toolchain.toml's, and a +# missing Apple target is added to it. set -eu source_dir="$SRCROOT/../native/replay" output_dir="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH" +resources_dir="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" target_dir="$TARGET_TEMP_DIR/icarus_replay_cargo" # Xcode's PATH does not include rustup's bin directory. cargo="${CARGO:-$HOME/.cargo/bin/cargo}" @@ -16,7 +19,10 @@ if [ -z "$cargo" ]; then echo "error: cargo not found; install Rust with rustup to build the replay decoder" >&2 exit 1 fi -mkdir -p "$output_dir" +mkdir -p "$output_dir" "$resources_dir" +# rustup picks the toolchain from rust-toolchain.toml in the working directory. +cd "$source_dir" +rustup="$(dirname "$cargo")/rustup" # Rust reads MACOSX_DEPLOYMENT_TARGET from the environment Xcode sets; the # install name is set at link time so nothing edits (and unsigns) the dylib. @@ -29,12 +35,20 @@ for architecture in $ARCHS; do x86_64) triple=x86_64-apple-darwin ;; *) echo "error: no Rust target for $architecture" >&2; exit 1 ;; esac + if [ -x "$rustup" ] && ! "$rustup" target list --installed | grep -qx "$triple"; then + "$rustup" target add "$triple" + fi "$cargo" build --release --locked --lib \ --manifest-path "$source_dir/Cargo.toml" \ --target "$triple" --target-dir "$target_dir" set -- "$@" "$target_dir/$triple/release/libicarus_replay.dylib" done xcrun lipo -create "$@" -output "$output_dir/libicarus_replay.dylib" +# Apache-2.0 section 4: vrfkit's notices and the license text ship with the +# library. Resources, not Frameworks: codesign takes every file in Frameworks +# for code. +cp "$source_dir/NOTICE.md" "$resources_dir/icarus_replay_NOTICE.md" +cp "$source_dir/../../LICENSE" "$resources_dir/icarus_replay_LICENSE.txt" if [ "${CODE_SIGNING_ALLOWED:-NO}" = YES ]; then codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" \ --timestamp=none "$output_dir/libicarus_replay.dylib" diff --git a/native/replay/include/icarus_replay.h b/native/replay/include/icarus_replay.h index 074e9f59c..010503b1f 100644 --- a/native/replay/include/icarus_replay.h +++ b/native/replay/include/icarus_replay.h @@ -1,6 +1,6 @@ /* * icarus_replay: decodes a Valorant .vrf replay into the buffer described in - * docs/replay-format.md. Three functions; every buffer they return is owned + * docs/replay-format.md. Every buffer the probe and decode return is owned * by the library until passed to icarus_replay_free, exactly once. * * When is_error is nonzero, ptr holds a UTF-8 JSON error object instead: @@ -36,17 +36,33 @@ typedef struct { IcarusReplayBuffer icarus_replay_probe(const char* path_utf8); /* - * Full decode: the .icrp buffer, or an error object. No callbacks; the - * caller owns two cells it polls/sets from another thread: - * progress written by the decoder with 0..10000 (hundredths of a percent), - * relaxed atomic stores; - * cancel read between chunks and packets; nonzero stops the decode with - * the `cancelled` error. - * Either pointer may be NULL. Both must be 4-byte aligned, stay valid until - * the call returns, and be accessed atomically by other threads. + * A decode's progress and cancel request. The library owns its memory and + * makes every access atomic, so other threads may poll and cancel through + * these functions while icarus_replay_decode runs. */ -IcarusReplayBuffer icarus_replay_decode(const char* path_utf8, uint32_t* progress, - const uint32_t* cancel); +typedef struct IcarusReplayControl IcarusReplayControl; + +/* A new control: progress 0, not cancelled. */ +IcarusReplayControl* icarus_replay_control_new(void); + +/* The decode's progress, 0..10000 (hundredths of a percent); 0 for NULL. */ +uint32_t icarus_replay_control_progress(const IcarusReplayControl* control); + +/* Stops the decode using the control with the `cancelled` error at its next + * frame or packet. NULL is ignored. */ +void icarus_replay_control_cancel(const IcarusReplayControl* control); + +/* Releases a control, once, after any decode using it has returned. NULL is + * ignored. */ +void icarus_replay_control_free(IcarusReplayControl* control); + +/* + * Full decode: the .icrp buffer, or an error object. No callbacks: progress + * and cancel go through `control`, which may be NULL and must not be freed + * until the call returns. + */ +IcarusReplayBuffer icarus_replay_decode(const char* path_utf8, + const IcarusReplayControl* control); /* Releases a buffer from either function. A NULL ptr is ignored. */ void icarus_replay_free(IcarusReplayBuffer buffer); diff --git a/native/replay/rust-toolchain.toml b/native/replay/rust-toolchain.toml new file mode 100644 index 000000000..70707a55b --- /dev/null +++ b/native/replay/rust-toolchain.toml @@ -0,0 +1,8 @@ +# The toolchain the replay decoder is built and tested with, everywhere: +# rustup reads this file from native/replay, which every cargo call runs in +# (CI, windows/CMakeLists.txt, build_macos.sh). Bump it together with the +# `dtolnay/rust-toolchain@` steps in .github/workflows. +[toolchain] +channel = "1.96.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/native/replay/src/analysis/casts.rs b/native/replay/src/analysis/casts.rs index cfef5395a..74c252fa3 100644 --- a/native/replay/src/analysis/casts.rs +++ b/native/replay/src/analysis/casts.rs @@ -41,6 +41,16 @@ struct Element { location: Option, } +/// One cast: an element with its whole identity, first sent at `first_send`. +struct Found { + first_send: u32, + player: String, + slot: i64, + round: i64, + cast_time: f64, + location: Option, +} + pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { // Replicated state per array element, and its state after each packet // that wrote to it. @@ -78,23 +88,22 @@ pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { // Identities in first-send order. let mut seen: HashSet<(String, i64, i64, u64)> = HashSet::new(); - let mut found: Vec<(u32, Element)> = Vec::new(); - for Snapshot { - time, - state: element, - .. - } in snapshots - { - let (Some(player), Some(slot), Some(round), Some(cast_time)) = ( - element.player.clone(), - element.slot, - element.round, - element.cast_time, - ) else { + let mut found: Vec = Vec::new(); + for Snapshot { time, state, .. } in snapshots { + let (Some(player), Some(slot), Some(round), Some(cast_time)) = + (state.player, state.slot, state.round, state.cast_time) + else { continue; }; - if seen.insert((player, round, slot, cast_time.to_bits())) { - found.push((time, element)); + if seen.insert((player.clone(), round, slot, cast_time.to_bits())) { + found.push(Found { + first_send: time, + player, + slot, + round, + cast_time, + location: state.location, + }); } } @@ -104,32 +113,28 @@ pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { .filter_map(|r| Some((i64::from(r.index), r.combat_start_ms?))) .collect(); let mut unplaced = 0; - let timed: Vec<(i64, Element)> = found + let timed: Vec<(i64, Found)> = found .into_iter() - .map(|(first_send, e)| { - let cast_ms = e - .round - .and_then(|r| buy_end.get(&r)) - .map(|&b| b + (e.cast_time.expect("identity has a time") * 1000.0).round() as i64); + .map(|f| { + // `as` saturates a wild wire value; the sum must too. + let cast_ms = buy_end + .get(&f.round) + .map(|&b| b.saturating_add((f.cast_time * 1000.0).round() as i64)); if cast_ms.is_none() { unplaced += 1; } - (cast_ms.unwrap_or(i64::from(first_send)), e) + (cast_ms.unwrap_or(i64::from(f.first_send)), f) }) .collect(); let classes = slot_classes(cx, &timed); let mut casts: Vec = timed .into_iter() - .map(|(time_ms, e)| { - let player = e.player.expect("identity has a player"); - let slot = e.slot.expect("identity has a slot"); - Cast { - time_ms, - class_path: classes.get(&(player.clone(), slot)).cloned(), - subject: player, - slot, - position: e.location.as_deref().and_then(parse_vector), - } + .map(|(time_ms, f)| Cast { + time_ms, + class_path: classes.get(&(f.player.clone(), f.slot)).cloned(), + subject: f.player, + slot: f.slot, + position: f.location.as_deref().and_then(parse_vector), }) .collect(); casts.sort_by(|a, b| (a.time_ms, &a.subject).cmp(&(b.time_ms, &b.subject))); @@ -145,7 +150,7 @@ pub(super) fn casts(cx: &mut Context<'_>, rounds: &[Round]) -> Vec { /// cast's first-send packet name one class without dissent, and no other /// slot of that player names it too (Reyna's two abilities spend one pool). /// Only ability items count: a gun's ammo is an `AuthResourceAmount` too. -fn slot_classes(cx: &Context<'_>, casts: &[(i64, Element)]) -> HashMap<(String, i64), String> { +fn slot_classes(cx: &Context<'_>, casts: &[(i64, Found)]) -> HashMap<(String, i64), String> { /// Spends within this of the cast belong to it. const WINDOW_MS: i64 = 10; // Charge decrements: (time, ability actor). @@ -164,10 +169,8 @@ fn slot_classes(cx: &Context<'_>, casts: &[(i64, Element)]) -> HashMap<(String, } } let mut votes: BTreeMap<(String, i64), BTreeMap> = BTreeMap::new(); - for (time, e) in casts { - let (Some(player), Some(slot)) = (&e.player, e.slot) else { - continue; - }; + for (time, Found { player, slot, .. }) in casts { + let slot = *slot; let mut candidates: Vec = spends .iter() .filter(|&&(t, _)| (t - time).abs() <= WINDOW_MS) diff --git a/native/replay/src/analysis/mod.rs b/native/replay/src/analysis/mod.rs index f1ca5b993..1aee6133c 100644 --- a/native/replay/src/analysis/mod.rs +++ b/native/replay/src/analysis/mod.rs @@ -16,7 +16,7 @@ use vrf_schema::NetGuidCache; use crate::collect::{Collector, Row}; use crate::decode::Walked; -use crate::document::{Decoder, Document, Match, Player, Quality, Vec3}; +use crate::document::{Decoder, Document, Loss, Match, Player, Quality, Vec3}; use crate::guard::GuardVerdict; use crate::header::{self, DECODER_VERSION, VRFKIT_VERSION}; @@ -273,9 +273,35 @@ pub(crate) fn analyse( let stats = &walked.stats; let net = &walked.net; + let loss = Loss { + malformed_packets: net.malformed_packets, + bunch_header_failures: net.bunch_header_failures, + lost_content_blocks: net.lost_content_blocks(), + rejected_partials: net.partial_errors, + unfinished_partials: net.unfinished_partials, + refused_bunches: net.channel_state_limit_failures, + unopened_channel_bunches: net.bunches_on_unopened_channel, + package_map_export_bunches: net.package_map_exports, + }; let mut warnings = std::mem::take(&mut cx.warnings); for (name, value) in [ - ("malformed packets", net.malformed_packets), + ("malformed packets", loss.malformed_packets), + ( + "bunch headers that did not read", + loss.bunch_header_failures, + ), + ("content blocks lost", loss.lost_content_blocks), + ("partial bunch fragments rejected", loss.rejected_partials), + ("partial bunches unfinished", loss.unfinished_partials), + ("bunches refused at a channel limit", loss.refused_bunches), + ( + "bunches on unopened channels", + loss.unopened_channel_bunches, + ), + ( + "package-map export bunches", + loss.package_map_export_bunches, + ), ("movement decode errors", stats.movement_rpc_errors), ("struct-blob decode failures", stats.struct_blobs_failed), ("array leaf decode errors", stats.array_leaf_decode_errors), @@ -321,6 +347,7 @@ pub(crate) fn analyse( quality: Quality { transform_verified: guard.verified, decode_errors: stats.overlay.decoded_err, + loss, warnings, }, }; diff --git a/native/replay/src/analysis/rounds.rs b/native/replay/src/analysis/rounds.rs index e42e8b1a4..202c545f4 100644 --- a/native/replay/src/analysis/rounds.rs +++ b/native/replay/src/analysis/rounds.rs @@ -20,6 +20,8 @@ struct Phases { buy_end: Option, post_round: Option, side_switch: Option, + /// Phases sent again: (phase, kept time, repeat time). + repeats: Vec<(i64, u32, u32)>, } impl Phases { @@ -34,6 +36,15 @@ impl Phases { }) } + /// A reset and nothing after it yet. + fn is_lone_reset(&self) -> bool { + self.reset.is_some() + && self.start.is_none() + && self.buy_end.is_none() + && self.post_round.is_none() + && self.side_switch.is_none() + } + fn open(&self) -> Option { [ self.reset, @@ -61,8 +72,6 @@ struct Placed { pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { let mut phases: Vec = Vec::new(); - // (round ordinal, phase, kept time, repeat time). - let mut repeated: Vec<(usize, i64, u32, u32)> = Vec::new(); let mut results: HashMap = HashMap::new(); let mut sites = Vec::new(); for row in cx.rows { @@ -71,18 +80,17 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { if !(2..=6).contains(&phase) { continue; } - if phase == 2 || phases.is_empty() { + // A reset opens the next cycle, unless it repeats a lone reset; + // the first phase seen opens one, whichever it is. + let repeat_reset = phases.last().is_some_and(Phases::is_lone_reset); + if phases.is_empty() || (phase == 2 && !repeat_reset) { phases.push(Phases::default()); } - let ordinal = phases.len() - 1; - let slot = phases - .last_mut() - .expect("pushed") - .slot(phase) - .expect("2..=6"); + let p = phases.last_mut().expect("pushed"); + let slot = p.slot(phase).expect("2..=6"); match *slot { None => *slot = Some(row.time), - Some(kept) => repeated.push((ordinal, phase, kept, row.time)), + Some(kept) => p.repeats.push((phase, kept, row.time)), } } else if &*row.name == "PlantedAtSite" { sites.push((row.time, row.int.unwrap_or(0))); @@ -97,22 +105,11 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { .insert(path.leaf().to_owned(), (row.int, row.text.clone())); } } - // Seen on 13.00: the buy phase ending twice, 0.4-0.9 s apart. The first - // is kept, so `combatStartMs` and the cast times built on it could be - // that much early. - for (ordinal, phase, kept, again) in repeated { - cx.warnings.push(format!( - "round {ordinal}: game phase {phase} sent again at {again} ms; the first, at {kept} ms, is kept" - )); - } - // The last side switch resets into a round that never starts. - if phases.last().is_some_and(|p| p.start.is_none()) { - phases.pop(); - } + let phases = started(cx, phases); let opens: Vec = phases .iter() - .map(|p| p.open().expect("a phase is set")) + .map(|(start, p)| p.open().unwrap_or(*start)) .collect(); let place = |t: u32| opens.partition_point(|&o| o <= t).checked_sub(1); let mut placed: Vec = phases.iter().map(|_| Placed::default()).collect(); @@ -158,7 +155,7 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { let mut rounds = Vec::new(); let mut unknown_results = Vec::new(); - for (ordinal, (p, facts)) in phases.iter().zip(&placed).enumerate() { + for (ordinal, ((phase_start, p), facts)) in phases.iter().zip(&placed).enumerate() { let single = |v: &[(u32, i64)]| (v.len() == 1).then(|| v[0]); let single_t = |v: &[(u32, ())]| (v.len() == 1).then(|| v[0].0); let round_number = single(&facts.round_number); @@ -175,10 +172,7 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { )); } } - let start = round_number - .map(|(t, _)| t) - .or(p.start) - .expect("rounds without a start are dropped"); + let start = round_number.map_or(*phase_start, |(t, _)| t); let result = round_number.and_then(|(_, n)| by_number.get(&n).copied()); let winning_team = result.and_then(|r| text(r, "WinningTeam")); // A match surrendered mid-round ends with no end phase but a result; @@ -254,14 +248,51 @@ pub(super) fn rounds(cx: &mut Context<'_>) -> Vec { rounds } +/// The phase cycles that started a round, each with its start phase's time. +/// The last side switch resets into a round that never starts, dropped +/// silently; any other cycle without a start (a replay that begins mid-round) +/// is dropped with a warning, its side switch moved to the round before. +/// Repeated phases are warned about under their round's ordinal. +fn started(cx: &mut Context<'_>, mut phases: Vec) -> Vec<(u32, Phases)> { + if phases.last().is_some_and(|p| p.start.is_none()) { + phases.pop(); + } + let mut started: Vec<(u32, Phases)> = Vec::new(); + for p in phases { + match p.start { + Some(start) => started.push((start, p)), + None => { + cx.warnings.push(format!( + "game phases from {} ms never started a round (dropped)", + p.open().unwrap_or(0) + )); + if let Some((_, before)) = started.last_mut() { + before.side_switch = before.side_switch.or(p.side_switch); + } + } + } + } + // Seen on 13.00: the buy phase ending twice, 0.4-0.9 s apart. The first + // is kept, so `combatStartMs` and the cast times built on it could be + // that much early. + for (ordinal, (_, p)) in started.iter().enumerate() { + for (phase, kept, again) in &p.repeats { + cx.warnings.push(format!( + "round {ordinal}: game phase {phase} sent again at {again} ms; the first, at {kept} ms, is kept" + )); + } + } + started +} + /// Rounds whose `RoundResults` entry is missing take their attackers from a /// round of the same half: sides hold between side switches (phase 6, sent /// on the round before the switch). -fn fill_attackers(cx: &mut Context<'_>, rounds: &mut [Round], phases: &[Phases]) { +fn fill_attackers(cx: &mut Context<'_>, rounds: &mut [Round], phases: &[(u32, Phases)]) { let mut half = 0; let halves: Vec = phases .iter() - .map(|p| { + .map(|(_, p)| { let this = half; if p.side_switch.is_some() { half += 1; @@ -442,6 +473,89 @@ fn economy(cx: &Context<'_>, buy_end: Option) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::collect::Row; + + /// The rounds of a replay whose game state sends `phases` `(time, phase)`. + fn rounds_of(phases: &[(u32, i64)]) -> (Vec, Vec) { + let rows: Vec = phases + .iter() + .map(|&(time, phase)| Row { + time, + packet: time, + actor: 1, + object: None, + group: "/Game/GameModes/Bomb/BombGameState.BombGameState_C".into(), + name: "MulticastSetPhase.NewPhase".into(), + int: Some(phase), + float: None, + boolean: None, + text: None, + raw: None, + }) + .collect(); + let cache = vrf_schema::NetGuidCache::new(); + let mut cx = Context { + rows: &rows, + events: &[], + actors: &[], + cache: &cache, + duration_ms: 10_000, + players: Vec::new(), + body_subject: HashMap::new(), + state_subject: HashMap::new(), + actor_class: HashMap::new(), + owner_writes: HashMap::new(), + instigator_writes: HashMap::new(), + warnings: Vec::new(), + }; + let rounds = rounds(&mut cx); + (rounds, cx.warnings) + } + + fn spans(rounds: &[Round]) -> Vec<(i64, i64)> { + rounds.iter().map(|r| (r.start_ms, r.end_ms)).collect() + } + + #[test] + fn a_reset_sent_twice_is_one_round() { + let (rounds, warnings) = rounds_of(&[ + (100, 2), + (150, 2), + (200, 3), + (300, 4), + (400, 5), + (500, 2), + (600, 3), + ]); + assert_eq!(spans(&rounds), [(200, 400), (600, 10_000)]); + assert!( + warnings + .iter() + .any(|w| w.starts_with("round 0: game phase 2 sent again at 150 ms")), + "{warnings:?}" + ); + } + + #[test] + fn cycles_that_never_start_are_dropped_not_a_panic() { + // A replay that begins mid-round, then a reset abandoned for another. + let (rounds, warnings) = rounds_of(&[ + (50, 4), + (60, 5), + (100, 2), + (120, 4), + (150, 2), + (200, 3), + (300, 4), + (400, 5), + ]); + assert_eq!(spans(&rounds), [(200, 400)]); + let dropped: Vec<&String> = warnings + .iter() + .filter(|w| w.contains("never started a round")) + .collect(); + assert_eq!(dropped.len(), 2, "{warnings:?}"); + } #[test] fn sites_and_reasons_map_as_documented() { diff --git a/native/replay/src/analysis/utility.rs b/native/replay/src/analysis/utility.rs index b6ff62068..54fb62100 100644 --- a/native/replay/src/analysis/utility.rs +++ b/native/replay/src/analysis/utility.rs @@ -14,7 +14,9 @@ //! Shape points come from each family's own RPCs (docs/replay-format.md, //! "Utility shape points"). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +use vrf_export::ActorRecord; use super::{Context, parse_vector, raw_vector, value_at}; use crate::collect::Move; @@ -100,61 +102,48 @@ pub(super) fn utility( } } - let mut open: HashMap = HashMap::new(); + let (lives, reopened) = lives(cx.actors); let mut out: Vec = Vec::new(); - let (mut no_position, mut reopened, mut no_owner) = (0, 0, 0); - for a in cx.actors { - match a.event { - "open" => { - let Some(class) = a.class_path.as_deref().filter(|c| is_utility(c)) else { - continue; - }; - if open.contains_key(&a.actor_net_guid) { - reopened += 1; - } - let spawn = match (a.spawn_x, a.spawn_y, a.spawn_z) { - (Some(x), Some(y), Some(z)) => Some([f64::from(x), f64::from(y), f64::from(z)]), - _ => None, - }; - let first_move = tracks.get(&a.actor_net_guid).and_then(|m| { - m.iter() - .find(|p| p[0] >= f64::from(a.time_ms)) - .map(|p| [p[1], p[2], p[3]]) - }); - let Some(position) = spawn.or(first_move) else { - no_position += 1; - continue; - }; - let owner = cx - .subject_behind(a.actor_net_guid, a.time_ms) - .map(str::to_owned); - if owner.is_none() && first_round_ms.is_some_and(|t| i64::from(a.time_ms) > t) { - no_owner += 1; - } - open.insert(a.actor_net_guid, out.len()); - out.push(Utility { - id: a.actor_net_guid, - class_path: class.to_owned(), - owner, - spawn_ms: i64::from(a.time_ms), - end_ms: None, - position, - // A spawn block carries its rotation only when it is not - // zero (vrf-net's spawn.rs reads it behind a flag bit), so - // a spawned actor without one faces yaw 0. - yaw: a.spawn_yaw.map(f64::from).or(spawn.map(|_| 0.0)), - path: None, - points: None, - }); - } - // Dormancy is not destruction (vrfkit CLAUDE.md): only a close ends it. - "close" => { - if let Some(i) = open.remove(&a.actor_net_guid) { - out[i].end_ms = Some(i64::from(a.time_ms)); - } - } - _ => {} + let (mut no_position, mut no_owner) = (0, 0); + for Life { + open: a, + class, + end_ms, + } in lives + { + let spawn = match (a.spawn_x, a.spawn_y, a.spawn_z) { + (Some(x), Some(y), Some(z)) => Some([f64::from(x), f64::from(y), f64::from(z)]), + _ => None, + }; + let first_move = tracks.get(&a.actor_net_guid).and_then(|m| { + m.iter() + .find(|p| p[0] >= f64::from(a.time_ms)) + .map(|p| [p[1], p[2], p[3]]) + }); + let Some(position) = spawn.or(first_move) else { + no_position += 1; + continue; + }; + let owner = cx + .subject_behind(a.actor_net_guid, a.time_ms) + .map(str::to_owned); + if owner.is_none() && first_round_ms.is_some_and(|t| i64::from(a.time_ms) > t) { + no_owner += 1; } + out.push(Utility { + id: a.actor_net_guid, + class_path: class.to_owned(), + owner, + spawn_ms: i64::from(a.time_ms), + end_ms: end_ms.map(i64::from), + position, + // A spawn block carries its rotation only when it is not zero + // (vrf-net's spawn.rs reads it behind a flag bit), so a spawned + // actor without one faces yaw 0. + yaw: a.spawn_yaw.map(f64::from).or(spawn.map(|_| 0.0)), + path: None, + points: None, + }); } trapwire_far_ends(cx, &out, &mut points); for u in &mut out { @@ -183,7 +172,10 @@ pub(super) fn utility( } for (n, what) in [ (no_position, "utility actor(s) with no position (dropped)"), - (reopened, "utility actor GUID(s) reopened while open"), + ( + reopened, + "utility actor open(s) for an actor already open and awake (kept as one)", + ), ( no_owner, "utility actor(s) spawned in play with no player owner", @@ -196,6 +188,57 @@ pub(super) fn utility( out } +/// One utility actor from its first open to its close. +struct Life<'a> { + open: &'a ActorRecord, + class: &'a str, + end_ms: Option, +} + +/// Every utility actor's life, in open order, and how many opens came for an +/// actor already open and awake. Dormancy is not destruction (vrfkit +/// CLAUDE.md): an actor that goes dormant opens again when it wakes, and it +/// stays the one life until a close. +fn lives(actors: &[ActorRecord]) -> (Vec>, usize) { + let mut out: Vec> = Vec::new(); + let mut open: HashMap = HashMap::new(); + let mut dormant: HashSet = HashSet::new(); + let mut reopened = 0; + for a in actors { + let guid = a.actor_net_guid; + match a.event { + "open" if open.contains_key(&guid) => { + if !dormant.remove(&guid) { + reopened += 1; + } + } + "open" => { + if let Some(class) = a.class_path.as_deref().filter(|c| is_utility(c)) { + open.insert(guid, out.len()); + out.push(Life { + open: a, + class, + end_ms: None, + }); + } + } + "dormant" => { + if open.contains_key(&guid) { + dormant.insert(guid); + } + } + "close" => { + dormant.remove(&guid); + if let Some(i) = open.remove(&guid) { + out[i].end_ms = Some(a.time_ms); + } + } + _ => {} + } + } + (out, reopened) +} + /// Cypher's Trapwire opens with a `_SecondWire` actor at its far anchor, in /// the same millisecond and under the same `Owner`; that anchor becomes the /// wire's one shape point. @@ -291,6 +334,37 @@ mod tests { assert_eq!(thin(std::iter::empty()), Vec::<[f64; 4]>::new()); } + #[test] + fn a_dormant_actor_that_wakes_is_one_life_until_it_closes() { + let smoke = "/Game/Characters/Wraith/S0/Ability_Smoke/GameObject_Wraith_4_Smoke.GameObject_Wraith_4_Smoke_C"; + let at = |time_ms: u32, guid: u32, event: &'static str| ActorRecord { + time_ms, + actor_net_guid: guid, + event, + class_path: Some(smoke.to_owned()), + ..ActorRecord::default() + }; + let actors = [ + at(100, 7, "open"), + at(200, 7, "dormant"), + at(300, 7, "open"), + at(400, 7, "close"), + // The GUID reused after a close is a new life. + at(500, 7, "open"), + ]; + let (found, reopened) = lives(&actors); + let spans: Vec<(u32, Option)> = + found.iter().map(|l| (l.open.time_ms, l.end_ms)).collect(); + assert_eq!(spans, [(100, Some(400)), (500, None)]); + assert_eq!(reopened, 0); + + // An open while open and awake is still one life, but warned about. + let twice = [at(100, 7, "open"), at(150, 7, "open"), at(400, 7, "close")]; + let (found, reopened) = lives(&twice); + assert_eq!(found.len(), 1); + assert_eq!((found[0].end_ms, reopened), (Some(400), 1)); + } + #[test] fn world_objects_are_utility_and_held_items_are_not() { assert!(is_utility( diff --git a/native/replay/src/analysis/vitals.rs b/native/replay/src/analysis/vitals.rs index 15af586b5..6c833b238 100644 --- a/native/replay/src/analysis/vitals.rs +++ b/native/replay/src/analysis/vitals.rs @@ -20,6 +20,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use super::Context; +use crate::collect::Row; use crate::document::{Round, VitalsRow}; use crate::fieldpath::FieldPath; @@ -48,10 +49,15 @@ enum Change { }, } +/// Where a change falls: its millisecond, then its place on the wire (packet, +/// then row), so changes sent in one millisecond apply in the order sent. +/// A round start leads its millisecond. +type Order = (u32, u32, usize); + pub(super) fn vitals(cx: &mut Context<'_>, rounds: &[Round]) -> BTreeMap> { - let mut changes: Vec<(u32, Change)> = rounds + let mut changes: Vec<(Order, Change)> = rounds .iter() - .filter_map(|r| Some((u32::try_from(r.start_ms).ok()?, Change::RoundStart))) + .filter_map(|r| Some(((u32::try_from(r.start_ms).ok()?, 0, 0), Change::RoundStart))) .collect(); // Armour items and their full points, by actor. @@ -60,21 +66,28 @@ pub(super) fn vitals(cx: &mut Context<'_>, rounds: &[Round]) -> BTreeMap, rounds: &[Round]) -> BTreeMap = Vec::new(); + // A section is a body's health or an armour item's armour by its name + // and outer in the GUID table; shield, overheal and ability sections are + // neither. + let mut health_results = 0; + for (order, section, value) in section_results(cx.rows) { + let Some(outer) = cx.cache.get_outer_guid(section).map(|o| o.0) else { + continue; + }; + match cx.cache.get_path_by_guid(section) { + Some("HealthDamageSection") => { + if let Some(subject) = cx.body_subject.get(&outer) { + health_results += 1; + let subject = subject.clone(); + changes.push((order, Change::Health { subject, value })); + } + } + Some("AttachedDamageSection") if full.contains_key(&outer) => { + changes.push((order, Change::Reported { item: outer, value })); + } + _ => {} + } + } + if health_results == 0 { + cx.warnings + .push("no health results replicated: vitals hold only each round's start".to_owned()); + } + + let subjects: Vec<&str> = cx.players.iter().map(|p| p.subject.as_str()).collect(); + fold(&subjects, &full, in_order(changes)) +} + +/// Every section result `(order, section, value)`: `MulticastNotifySetLife` +/// rows, and the life-change elements of the damage, heal and reset RPCs, +/// whose `ChangedComponent` and `LifeResult` arrive as separate rows of one +/// call, placed at the first. +fn section_results(rows: &[Row]) -> Vec<(Order, u32, f64)> { + let mut results: Vec<(Order, u32, f64)> = Vec::new(); let mut elements: HashMap, (Option, Option)> = HashMap::new(); - let mut order: Vec<(u32, ElementKey<'_>)> = Vec::new(); - for row in cx.rows { + let mut order: Vec<(Order, ElementKey<'_>)> = Vec::new(); + for (i, row) in rows.iter().enumerate() { + let at = (row.time, row.packet, i + 1); if &*row.name == "MulticastNotifySetLife.NewLife" { if let (Some(section), Some(value)) = (row.object, row.float) { - results.push((row.time, section, value)); + results.push((at, section, value)); } continue; } @@ -111,7 +159,7 @@ pub(super) fn vitals(cx: &mut Context<'_>, rounds: &[Round]) -> BTreeMap, rounds: &[Round]) -> BTreeMap {} } } - for (time, key) in order { + for (at, key) in order { if let (Some(section), Some(value)) = elements[&key] { - results.push((time, section, value)); + results.push((at, section, value)); } } + results +} - // A section is a body's health or an armour item's armour by its name - // and outer in the GUID table; shield, overheal and ability sections are - // neither. - let mut health_results = 0; - for (time, section, value) in results { - let Some(outer) = cx.cache.get_outer_guid(section).map(|o| o.0) else { - continue; - }; - match cx.cache.get_path_by_guid(section) { - Some("HealthDamageSection") => { - if let Some(subject) = cx.body_subject.get(&outer) { - health_results += 1; - let subject = subject.clone(); - changes.push((time, Change::Health { subject, value })); - } - } - Some("AttachedDamageSection") if full.contains_key(&outer) => { - changes.push((time, Change::Reported { item: outer, value })); - } - _ => {} - } - } - if health_results == 0 { - cx.warnings - .push("no health results replicated: vitals hold only each round's start".to_owned()); - } - - // Stable: round starts lead their millisecond, the wire keeps its order. +/// `changes` in the order they happened, each with its millisecond. +fn in_order(mut changes: Vec<(Order, Change)>) -> Vec<(u32, Change)> { changes.sort_by_key(|c| c.0); - let subjects: Vec<&str> = cx.players.iter().map(|p| p.subject.as_str()).collect(); - fold(&subjects, &full, changes) + changes + .into_iter() + .map(|((time, _, _), c)| (time, c)) + .collect() } /// One life-change element: (packet, actor, component, element indices, @@ -178,10 +204,14 @@ fn fold( let mut closed: HashSet = HashSet::new(); let mut out: BTreeMap> = BTreeMap::new(); let mut started = false; + // Every row in a round start's millisecond is kept, repeat or not. + let mut round_ms = None; for (time, change) in changes { - let round_start = change == Change::RoundStart; - started |= round_start; + if change == Change::RoundStart { + round_ms = Some(time); + started = true; + } let touched: Vec = match change { Change::RoundStart => { for &s in subjects { @@ -221,22 +251,25 @@ fn fold( .copied() .unwrap_or(0.0); let rows = out.entry(subject).or_default(); - push(rows, (i64::from(time), h, armor), round_start); + push(rows, (i64::from(time), h, armor), round_ms == Some(time)); } } out } -/// Append `row` unless it repeats the last row's values and is not forced; -/// a row at the last row's millisecond replaces it. -fn push(rows: &mut Vec, row: VitalsRow, force: bool) { - if let Some(last) = rows.last() { - if !force && (last.1, last.2) == (row.1, row.2) { - return; - } - if last.0 == row.0 { - rows.pop(); - } +/// Append `row`, replacing a row at the same millisecond, unless it then +/// repeats the row before it and is not kept: changes in one millisecond +/// that cancel out leave no row. +fn push(rows: &mut Vec, row: VitalsRow, keep: bool) { + if rows.last().is_some_and(|last| last.0 == row.0) { + rows.pop(); + } + if !keep + && rows + .last() + .is_some_and(|last| (last.1, last.2) == (row.1, row.2)) + { + return; } rows.push(row); } @@ -371,6 +404,89 @@ mod tests { ); } + fn row(packet: u32, name: &str, object: u32, int: Option, float: Option) -> Row { + Row { + time: 20, + packet, + actor: 5, + object: Some(object), + group: "/Game/G.G_C".into(), + name: name.into(), + int, + float, + boolean: None, + text: None, + raw: None, + } + } + + #[test] + fn a_millisecond_applies_its_results_in_wire_order() { + // In one millisecond, damage to 40 in one packet, then a regen tick + // to 45 in the next: the regen is the result that stands. + let damage = "MulticastNotifyDamage_Point.LifeChangeEvents[0]"; + let rows = [ + row(5, &format!("{damage}.ChangedComponent"), 3, Some(9), None), + row(5, &format!("{damage}.LifeResult"), 3, None, Some(40.0)), + row(6, "MulticastNotifySetLife.NewLife", 9, None, Some(45.0)), + ]; + let changes = section_results(&rows) + .into_iter() + .map(|(at, section, value)| { + assert_eq!(section, 9); + (at, health("a", value)) + }) + .chain([((20, 0, 0), Change::RoundStart)]) + .collect(); + assert_eq!( + in_order(changes), + [ + (20, Change::RoundStart), + (20, health("a", 40.0)), + (20, health("a", 45.0)) + ] + ); + } + + #[test] + fn changes_that_cancel_out_in_a_millisecond_leave_no_row() { + let rows = play(vec![ + (10, Change::RoundStart), + (12, assigned("a", 8)), + // Swapped for an item of the same points in one millisecond. + (20, Change::Closed { item: 8 }), + (20, assigned("a", 9)), + ( + 20, + Change::Reported { + item: 9, + value: 25.0, + }, + ), + ]); + assert_eq!(rows["a"], vec![(10, 100.0, 0.0), (12, 100.0, 25.0)]); + // A round start's row stays even when the changes in its millisecond + // bring back the values before it. + let rows = play(vec![ + (10, Change::RoundStart), + (12, assigned("a", 8)), + (30, Change::RoundStart), + (30, Change::Closed { item: 8 }), + (30, assigned("a", 9)), + ( + 30, + Change::Reported { + item: 9, + value: 25.0, + }, + ), + ]); + assert_eq!( + rows["a"], + vec![(10, 100.0, 0.0), (12, 100.0, 25.0), (30, 100.0, 25.0)] + ); + } + #[test] fn a_later_change_in_the_same_millisecond_replaces_the_row() { let rows = play(vec![ diff --git a/native/replay/src/bin/icarus-replay-dump.rs b/native/replay/src/bin/icarus-replay-dump.rs index 7aa8241e6..0be47192d 100644 --- a/native/replay/src/bin/icarus-replay-dump.rs +++ b/native/replay/src/bin/icarus-replay-dump.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use icarus_replay::decode::{Control, Observer}; -use icarus_replay::{container, header, oodle}; +use icarus_replay::{ReplayError, container, header, oodle}; fn main() { let args: Vec = std::env::args().skip(1).collect(); @@ -110,11 +110,16 @@ struct Survey { } impl Observer for Survey { - fn on_event(&mut self, event: vrf_export::EventRecord) { + fn on_event(&mut self, event: vrf_export::EventRecord) -> Result<(), ReplayError> { *self.events.entry(event.group).or_default() += 1; + Ok(()) } - fn on_packet(&mut self, records: &icarus_replay::RecordBuffers, _: &vrf_schema::NetGuidCache) { + fn on_packet( + &mut self, + records: &icarus_replay::RecordBuffers, + _: &vrf_schema::NetGuidCache, + ) -> Result<(), ReplayError> { self.movement_rows += records.movement.len() as u64; for a in &records.actors { let class = a.class_path.clone().unwrap_or_else(|| "?".into()); @@ -176,6 +181,7 @@ impl Observer for Survey { .push(format!("{}@{}:{v}", f.actor_net_guid, f.time_ms)); } } + Ok(()) } } diff --git a/native/replay/src/collect.rs b/native/replay/src/collect.rs index e0dcb9c16..69e32cebd 100644 --- a/native/replay/src/collect.rs +++ b/native/replay/src/collect.rs @@ -8,7 +8,9 @@ use vrf_export::{ActorRecord, EventRecord, FieldRecord}; use vrf_schema::NetGuidCache; use crate::decode::Observer; +use crate::error::ReplayError; use crate::guard::GuardSample; +use crate::limits::{self, MAX_RETAINED_BYTES}; use crate::vendor::sink::RecordBuffers; /// One field row the analysis reads. `name` is vrfkit's field name as @@ -48,14 +50,50 @@ pub struct Collector { pub guard: GuardSample, /// `wanted` per interned name; the `Arc` held here keeps its address unique. verdicts: std::collections::HashMap, bool)>, + /// Bytes of the records above, against [`MAX_RETAINED_BYTES`]. + retained: usize, +} + +impl Collector { + fn retain(&mut self, bytes: usize) -> Result<(), ReplayError> { + self.retained = self.retained.saturating_add(bytes); + if self.retained > MAX_RETAINED_BYTES { + return Err(limits::exceeded( + "retained record bytes", + MAX_RETAINED_BYTES, + )); + } + Ok(()) + } } impl Observer for Collector { - fn on_event(&mut self, event: EventRecord) { + fn on_event(&mut self, event: EventRecord) -> Result<(), ReplayError> { + self.retain( + size_of::() + + event.raw_payload.len() + + event.group.len() + + event.metadata.len() + + event.id.len(), + )?; self.events.push(event); + Ok(()) } - fn on_packet(&mut self, records: &RecordBuffers, _cache: &NetGuidCache) { + fn on_packet( + &mut self, + records: &RecordBuffers, + _cache: &NetGuidCache, + ) -> Result<(), ReplayError> { + let text = |s: &Option| s.as_ref().map_or(0, String::len); + self.retain( + records + .actors + .iter() + .map(|a| size_of::() + text(&a.class_path) + text(&a.archetype_path)) + .sum::() + + records.movement.len() * size_of::(), + )?; self.actors.extend(records.actors.iter().cloned()); self.moves.extend(records.movement.iter().map(|m| Move { time: m.time_ms, @@ -80,7 +118,7 @@ impl Observer for Collector { if !keep { continue; } - self.rows.push(Row { + let row = Row { time: field.time_ms, packet: field.packet_id, actor: field.actor_net_guid, @@ -92,8 +130,11 @@ impl Observer for Collector { boolean: field.value_bool, text: field.value_str.clone(), raw: untyped_bytes(field), - }); + }; + self.retain(size_of::() + text(&row.text) + row.raw.as_ref().map_or(0, Vec::len))?; + self.rows.push(row); } + Ok(()) } } @@ -173,3 +214,33 @@ fn wanted(name: &str) -> bool { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_past_the_retained_cap_are_refused() { + // vrfkit clones an actor's class path into every record of it, so a + // long path opened often adds up without the wire growing. + let opens = RecordBuffers { + actors: vec![ + ActorRecord { + class_path: Some("/Game/X".repeat(1024)), + ..ActorRecord::default() + }; + 4 + ], + ..RecordBuffers::default() + }; + let cache = NetGuidCache::new(); + let mut collector = Collector::default(); + collector.on_packet(&opens, &cache).unwrap(); + assert_eq!(collector.actors.len(), 4); + + collector.retained = MAX_RETAINED_BYTES - 1024; + let err = collector.on_packet(&opens, &cache).unwrap_err(); + assert_eq!(err.code, crate::error::ErrorCode::Corrupt); + assert_eq!(collector.actors.len(), 4, "the refused packet is not kept"); + } +} diff --git a/native/replay/src/decode.rs b/native/replay/src/decode.rs index 3fb79f344..24525190f 100644 --- a/native/replay/src/decode.rs +++ b/native/replay/src/decode.rs @@ -5,8 +5,9 @@ //! The per-packet pass is vrfkit's (`vendor::pass`); this module owns the //! chunk loop that vrfkit's CLI keeps in `pass::for_each_chunk` and the //! driver, because ours decompresses through the Oodle seam, reports -//! progress and can be cancelled. +//! progress, can be cancelled and holds the walk to `crate::limits`. +use std::cell::Cell; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::mpsc::sync_channel; use std::thread; @@ -21,11 +22,13 @@ use vrf_net::stats::NetStats; use vrf_schema::{FxHashMap, NetGuidCache}; use crate::error::{ErrorCode, ReplayError}; +use crate::limits; use crate::oodle::{Decompressor, inflate_replay_data}; use crate::vendor::pass::Pass; use crate::vendor::sink::{ExportStats, PlayerIdentity, RecordBuffers}; -/// The caller's progress and cancel cells (the C ABI's two pointers). +/// A decode's progress and cancel cells, borrowed (the C ABI lends its +/// `IcarusReplayControl`'s). #[derive(Default, Clone, Copy)] pub struct Control<'a> { /// 0..=10000, hundredths of a percent; relaxed stores. @@ -61,12 +64,16 @@ impl Control<'_> { } } -/// What the walk hands its consumer. +/// What the walk hands its consumer. An error stops the walk with it. pub trait Observer { /// One Event chunk, parsed as vrfkit's driver parses it. - fn on_event(&mut self, event: EventRecord); + fn on_event(&mut self, event: EventRecord) -> Result<(), ReplayError>; /// One packet's records; `cache` is the GUID table as of that packet. - fn on_packet(&mut self, records: &RecordBuffers, cache: &NetGuidCache); + fn on_packet( + &mut self, + records: &RecordBuffers, + cache: &NetGuidCache, + ) -> Result<(), ReplayError>; } /// What the walk counted, and the state the analysis reads afterwards. @@ -133,6 +140,7 @@ pub fn walk( stream_failures: Vec::new(), }; + let mut inflated = limits::Inflated::default(); thread::scope(|scope| -> Result<(), ReplayError> { // Rendezvous: the helper holds at most one decompressed chunk ahead // (Oodle is order-free and a fifth of the work in vrfkit's measure). @@ -155,11 +163,10 @@ pub fn walk( }); for chunk in chunks() { control.check()?; - let (kind, payload, end) = - chunk.map_err(|e| crate::header::container_error(&e).with_build(build))?; + let (kind, payload, end) = chunk.map_err(|e| crate::header::container_error(&e))?; match kind { ChunkType::ReplayData => { - let Ok(inflated) = rx.recv() else { + let Ok(inflated_chunk) = rx.recv() else { // The helper stops early only when cancelled. control.check()?; return Err(ReplayError::new( @@ -167,23 +174,33 @@ pub fn walk( "decompression stopped", )); }; - let frames = inflated.map_err(|e| e.with_build(build))?; - let finished = pass - .walk(&frames, &mut stats, |records, cache| { - observer.on_packet(records, cache); - !control.cancelled() - }) - .map_err(|e| { - ReplayError::new(ErrorCode::Corrupt, e.to_string()).with_build(build) - })?; - if !finished { - return Err(ReplayError::cancelled()); + let frames = inflated_chunk?; + inflated.add(frames.len())?; + // Why a callback stopped the walk, between frames or packets. + let stop = Cell::new(None); + let go_on = |result: Result<(), ReplayError>| match result + .and_then(|()| control.check()) + { + Ok(()) => true, + Err(e) => { + stop.set(Some(e)); + false + } + }; + pass.walk( + &frames, + &mut stats, + |exports, cache| go_on(limits::admit_exports(exports, cache)), + |records, cache| go_on(observer.on_packet(records, cache)), + ) + .map_err(|e| ReplayError::new(ErrorCode::Corrupt, e.to_string()))?; + if let Some(e) = stop.take() { + return Err(e); } } ChunkType::Event => { - let event = parse_event(payload, &mut walked.event_layout_mismatches) - .map_err(|e| e.with_build(build))?; - observer.on_event(event); + let event = parse_event(payload, &mut walked.event_layout_mismatches)?; + observer.on_event(event)?; } // Checkpoints repeat state the stream already carried. ChunkType::Checkpoint | ChunkType::Header => {} @@ -192,10 +209,16 @@ pub fn walk( control.report(WALK_SHARE * end as f64 / total); } Ok(()) + }) + .map_err(|e| match e.build { + Some(_) => e, + None => e.with_build(build), })?; pass.finish(); - observer.on_packet(&pass.buffers, &pass.cache); + observer + .on_packet(&pass.buffers, &pass.cache) + .map_err(|e| e.with_build(build))?; walked.packets = pass.packets; walked.net = pass.reader.stats().clone(); walked.stream_failures = pass.channels.stream_failures().to_vec(); diff --git a/native/replay/src/document.rs b/native/replay/src/document.rs index f37c5b3b6..82874052c 100644 --- a/native/replay/src/document.rs +++ b/native/replay/src/document.rs @@ -167,10 +167,41 @@ pub struct MovementSpan { #[serde(rename_all = "camelCase")] pub struct Quality { pub transform_verified: bool, + /// Typed field decodes that failed (vrfkit's `overlay.decoded_err`). pub decode_errors: u64, + pub loss: Loss, pub warnings: Vec, } +/// What the replication stream lost, as vrfkit's `validate` verdict counts +/// it (`vrf_net::stats::NetStats`). No two count the same bunch or block. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Loss { + /// Packets abandoned part-read (`malformed_packets`). + pub malformed_packets: u64, + /// Bunches whose header did not read (`bunch_header_failures`). + pub bunch_header_failures: u64, + /// Content blocks whose payload reached no field or RPC + /// (`lost_content_blocks()`): a payload kept whole is not lost. + pub lost_content_blocks: u64, + /// Partial-bunch fragments refused during reassembly, at a resource + /// limit included (`partial_errors`). + pub rejected_partials: u64, + /// Partial bunches still incomplete when the stream ended + /// (`unfinished_partials`). + pub unfinished_partials: u64, + /// Bunches refused at a channel-state limit + /// (`channel_state_limit_failures`). + pub refused_bunches: u64, + /// Bunches on a channel with no open actor, dropped whole + /// (`bunches_on_unopened_channel`). + pub unopened_channel_bunches: u64, + /// Package-map export bunches, whose content after the exports is not + /// read (`package_map_exports`). + pub package_map_export_bunches: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/native/replay/src/ffi.rs b/native/replay/src/ffi.rs index d2ffb9d0b..dc481d77a 100644 --- a/native/replay/src/ffi.rs +++ b/native/replay/src/ffi.rs @@ -1,9 +1,10 @@ //! The C ABI (include/icarus_replay.h). The only `unsafe` in the crate: it -//! reads the caller's path and cells and hands back buffers it allocated. +//! reads the caller's path, hands back buffers it allocated, and lends out +//! the decode control it owns. use std::ffi::{CStr, c_char}; use std::path::PathBuf; -use std::sync::atomic::AtomicU32; +use std::sync::atomic::{AtomicU32, Ordering}; use crate::decode::Control; use crate::error::{ErrorCode, ReplayError}; @@ -69,28 +70,85 @@ pub unsafe extern "C" fn icarus_replay_probe(path_utf8: *const c_char) -> Icarus }) } -/// Full decode to an `.icrp` buffer, or an error object. `progress` receives -/// 0..10000 by relaxed stores; a nonzero `cancel` stops the decode with -/// `cancelled`. Either may be null. +/// A decode's progress and cancel request, shared between the decoding +/// thread and the caller's. Opaque to C; every access is atomic. +#[derive(Default)] +pub struct IcarusReplayControl { + progress: AtomicU32, + cancel: AtomicU32, +} + +impl IcarusReplayControl { + fn view(&self) -> Control<'_> { + Control { + progress: Some(&self.progress), + cancel: Some(&self.cancel), + } + } +} + +/// A new control: progress 0, not cancelled. Free it with +/// `icarus_replay_control_free` once no decode is using it. +#[unsafe(no_mangle)] +pub extern "C" fn icarus_replay_control_new() -> *mut IcarusReplayControl { + Box::into_raw(Box::default()) +} + +/// The decode's progress, 0..10000 (hundredths of a percent). 0 for null. +/// +/// # Safety +/// `control` is null or a live control from `icarus_replay_control_new`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_control_progress( + control: *const IcarusReplayControl, +) -> u32 { + // SAFETY: null or live, by the caller's contract. + unsafe { control.as_ref() }.map_or(0, |c| c.progress.load(Ordering::Relaxed)) +} + +/// Ask the decode using `control` to stop with `cancelled`. Null is ignored. +/// +/// # Safety +/// As for `icarus_replay_control_progress`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_control_cancel(control: *const IcarusReplayControl) { + // SAFETY: null or live, by the caller's contract. + if let Some(c) = unsafe { control.as_ref() } { + c.cancel.store(1, Ordering::Relaxed); + } +} + +/// Release a control. Null is ignored. +/// +/// # Safety +/// `control` is null or came from `icarus_replay_control_new`, is freed once, +/// and no `icarus_replay_decode` call is still using it. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn icarus_replay_control_free(control: *mut IcarusReplayControl) { + if !control.is_null() { + // SAFETY: leaked by `icarus_replay_control_new`, freed once. + drop(unsafe { Box::from_raw(control) }); + } +} + +/// Full decode to an `.icrp` buffer, or an error object. `control`, when not +/// null, receives progress and is checked for a cancel request between +/// frames and packets. /// /// # Safety -/// `path_utf8` as for `icarus_replay_probe`. `progress` and `cancel` are null -/// or 4-byte-aligned cells that stay valid until this call returns; other -/// threads may touch them only with atomic operations. +/// `path_utf8` as for `icarus_replay_probe`. `control` is null or a live +/// control that is not freed until this call returns. #[unsafe(no_mangle)] pub unsafe extern "C" fn icarus_replay_decode( path_utf8: *const c_char, - progress: *mut u32, - cancel: *const u32, + control: *const IcarusReplayControl, ) -> IcarusReplayBuffer { // SAFETY: forwarded caller contract. let path = unsafe { path_from(path_utf8) }; - // SAFETY: aligned, live for the call, and only touched atomically; AtomicU32 - // has u32's size and alignment. - let progress = unsafe { progress.cast::().as_ref() }; - // SAFETY: as above; only loaded. - let cancel = unsafe { cancel.cast::().as_ref() }; - guarded(|| crate::decode_file(&path?, Control { progress, cancel })) + // SAFETY: null or live for the whole call, by the caller's contract. + let control = + unsafe { control.as_ref() }.map_or_else(Control::default, IcarusReplayControl::view); + guarded(|| crate::decode_file(&path?, control)) } /// Release a buffer from `icarus_replay_probe` or `icarus_replay_decode`. @@ -119,9 +177,8 @@ mod tests { #[test] fn errors_cross_the_abi_as_json_and_free_cleanly() { let path = c"this/does/not/exist.vrf"; - // SAFETY: a valid C string; null cells. - let buffer = - unsafe { icarus_replay_decode(path.as_ptr(), std::ptr::null_mut(), std::ptr::null()) }; + // SAFETY: a valid C string; no control. + let buffer = unsafe { icarus_replay_decode(path.as_ptr(), std::ptr::null()) }; assert_eq!(buffer.is_error, 1); let json: serde_json::Value = serde_json::from_slice(read(&buffer)).unwrap(); assert_eq!(json["code"], "io"); @@ -146,6 +203,33 @@ mod tests { }; } + #[test] + fn a_control_carries_cancel_in_and_progress_out() { + let control = icarus_replay_control_new(); + // SAFETY: a live control, freed once at the end. + unsafe { + let view = (*control).view(); + assert_eq!(icarus_replay_control_progress(control), 0); + view.report(0.5); + assert_eq!(icarus_replay_control_progress(control), 5_000); + assert!(!view.cancelled()); + icarus_replay_control_cancel(control); + assert!(view.cancelled()); + + let path = c"this/does/not/exist.vrf"; + let buffer = icarus_replay_decode(path.as_ptr(), control); + assert_eq!(buffer.is_error, 1); + icarus_replay_free(buffer); + icarus_replay_control_free(control); + } + // SAFETY: null is allowed everywhere. + unsafe { + assert_eq!(icarus_replay_control_progress(std::ptr::null()), 0); + icarus_replay_control_cancel(std::ptr::null()); + icarus_replay_control_free(std::ptr::null_mut()); + } + } + #[test] fn an_empty_success_buffer_round_trips() { let buffer = IcarusReplayBuffer::from_result(Ok(Vec::new())); diff --git a/native/replay/src/header.rs b/native/replay/src/header.rs index 1096f0d96..2e8917910 100644 --- a/native/replay/src/header.rs +++ b/native/replay/src/header.rs @@ -139,7 +139,8 @@ pub fn container_error(error: &ContainerError) -> ReplayError { /// file as the Header chunk needs. pub fn read_preamble(path: &Path) -> Result { let mut file = std::fs::File::open(path)?; - let len = file.metadata()?.len(); + // Past the cap a decode refuses the file anyway. + let len = file.metadata()?.len().min(crate::limits::MAX_FILE_BYTES); let mut data = Vec::new(); let mut want: u64 = 64 * 1024; loop { diff --git a/native/replay/src/lib.rs b/native/replay/src/lib.rs index 6b7c5e7d6..d706e62ed 100644 --- a/native/replay/src/lib.rs +++ b/native/replay/src/lib.rs @@ -11,10 +11,12 @@ mod ffi; mod fieldpath; pub mod guard; pub mod header; +pub mod limits; pub mod oodle; pub mod selkie; mod vendor; +use std::io::Read; use std::path::Path; pub use error::{ErrorCode, ReplayError}; @@ -50,10 +52,28 @@ pub fn decode_file_with( None => e.with_build(&build), }; control.check().map_err(named)?; - let data = std::fs::read(path).map_err(|e| named(e.into()))?; + let data = read_replay(path).map_err(named)?; decode_bytes(&data, &build, decompressor, control).map_err(named) } +/// The whole file, unless it is larger than [`limits::MAX_FILE_BYTES`]. +fn read_replay(path: &Path) -> Result, ReplayError> { + let too_large = || limits::exceeded("replay file bytes", limits::MAX_FILE_BYTES); + let file = std::fs::File::open(path)?; + let length = file.metadata()?.len(); + if length > limits::MAX_FILE_BYTES { + return Err(too_large()); + } + let mut data = Vec::with_capacity(length as usize); + // Bounded again in case the file grows while it is read. + file.take(limits::MAX_FILE_BYTES + 1) + .read_to_end(&mut data)?; + if data.len() as u64 > limits::MAX_FILE_BYTES { + return Err(too_large()); + } + Ok(data) +} + /// What the transform guard measures on `data` read with `transform_branch`'s /// transform: the walk without the analysis, for tests and diagnostics. pub fn measure_guard( @@ -111,6 +131,35 @@ pub fn decode_bytes( let analysed = analysis::analyse(&preamble, &collector, &walked, verdict); let json = serde_json::to_string(&analysed.document) .map_err(|e| ReplayError::new(ErrorCode::Corrupt, format!("document: {e}")))?; + limits::check_output(json.len(), analysed.blob.len())?; control.report(1.0); Ok(container::write(&json, &analysed.blob)) } + +#[cfg(test)] +mod tests { + use super::*; + use vrf_testkit::{Info, chunk, header_payload_for_branch, replay_info}; + + #[test] + fn a_file_past_the_size_cap_is_refused_unread() { + let path = std::env::temp_dir().join(format!("icarus-huge-{}.vrf", std::process::id())); + let mut head = replay_info(&Info::default()); + head.extend(chunk( + 0, + &header_payload_for_branch("++Ares-Core+release-13.00", 0, &[3, 0, 0, 0, 49, 56, 0]), + )); + std::fs::write(&path, &head).unwrap(); + // Only the length grows; nothing past the header is written. + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_len(limits::MAX_FILE_BYTES + 1).unwrap(); + drop(file); + + let result = decode_file_with(&path, &oodle::Unavailable, Control::default()); + std::fs::remove_file(&path).unwrap(); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt, "{err}"); + assert!(err.message.contains("file"), "{err}"); + assert_eq!(err.build.as_deref(), Some("++Ares-Core+release-13.00")); + } +} diff --git a/native/replay/src/limits.rs b/native/replay/src/limits.rs new file mode 100644 index 000000000..3c8b67c48 --- /dev/null +++ b/native/replay/src/limits.rs @@ -0,0 +1,310 @@ +//! Caps on what one replay can make the decoder hold. A crafted file could +//! otherwise ask for more memory than the machine has, and a failed +//! allocation aborts the app: `catch_unwind` cannot turn it into an error. +//! Past a cap the decode stops with `corrupt` instead. +//! +//! Each cap sits several times above what our 7-replay corpus needs. Its +//! largest, 1fb53a2c (30 rounds, 50 minutes, 91 MB), sets the figure beside +//! each; the caps that grow with a match's length leave room for about four +//! times as long a one. + +use vrf_bitio::BitReader; +use vrf_schema::NetGuidCache; + +use crate::error::{ErrorCode, ReplayError}; + +/// The `.vrf` file, read whole. Real replays are 10-90 MB. +pub const MAX_FILE_BYTES: u64 = 512 << 20; +/// One ReplayData chunk, inflated. Corpus: 11.4 MB. +pub const MAX_CHUNK_BYTES: usize = 64 << 20; +/// Every ReplayData chunk together, inflated. Corpus: 221 MB. +pub const MAX_INFLATED_BYTES: u64 = 1 << 30; +/// Export groups in the schema. Corpus: 540. +pub const MAX_EXPORT_GROUPS: usize = 16_384; +/// Field slots across those groups, 32 bytes each however few are filled: +/// a group declares its slot count up front. Corpus: 10,788. +pub const MAX_EXPORT_SLOTS: usize = 1 << 20; +/// NetGUID paths registered or changed. Corpus: 28,422. +pub const MAX_GUID_CHANGES: u64 = 1 << 20; +/// Records the collector keeps for the analysis, by their size and the +/// strings and bytes they own (their vectors' spare capacity aside). +/// Corpus: 113 MB. +pub const MAX_RETAINED_BYTES: usize = 512 << 20; +/// The `.icrp` buffer handed back. Corpus: 74 MB. +pub const MAX_OUTPUT_BYTES: usize = 512 << 20; + +/// The `corrupt` error for a replay past one of these caps. +pub fn exceeded(what: &str, cap: impl std::fmt::Display) -> ReplayError { + ReplayError::new( + ErrorCode::Corrupt, + format!("{what} exceed the decoder's cap of {cap}"), + ) +} + +/// ReplayData inflated so far. +#[derive(Default)] +pub struct Inflated(u64); + +impl Inflated { + /// Count one chunk inflated to `bytes`. + pub fn add(&mut self, bytes: usize) -> Result<(), ReplayError> { + self.0 += bytes as u64; + if self.0 > MAX_INFLATED_BYTES { + return Err(exceeded("inflated replay data bytes", MAX_INFLATED_BYTES)); + } + Ok(()) + } +} + +/// Before a frame's ExportData reaches `cache`: refuse a schema the caps +/// cannot hold. `exports` is a reader at the section's start; the commands +/// are read ahead on a copy, because `read_net_field_exports` allocates +/// every slot a group declares before the next command is read. +pub fn admit_exports(exports: &BitReader<'_>, cache: &NetGuidCache) -> Result<(), ReplayError> { + // GUID paths are registered by the previous frames' ExportData, so this + // catches them a frame late; one frame holds few. + if cache.guid_generation() > MAX_GUID_CHANGES { + return Err(exceeded("NetGUID path changes", MAX_GUID_CHANGES)); + } + let declared = Declared::read(exports.clone()); + if declared.groups == 0 { + return Ok(()); + } + if cache.group_count() + declared.groups > MAX_EXPORT_GROUPS { + return Err(exceeded("export groups", MAX_EXPORT_GROUPS)); + } + let slots: usize = cache.groups().iter().map(|g| g.fields.len()).sum(); + if slots.saturating_add(declared.slots) > MAX_EXPORT_SLOTS { + return Err(exceeded("export field slots", MAX_EXPORT_SLOTS)); + } + Ok(()) +} + +/// What one frame's net-field export commands declare, in the layout +/// vrf-schema's `read_net_field_exports` reads. Every declaration counts as +/// a new group with all its slots: a re-declared group allocates them again +/// before merging. +#[derive(Debug, Default, PartialEq)] +struct Declared { + groups: usize, + slots: usize, +} + +/// The cap vrf-schema holds an FString to. +const MAX_FSTRING_BYTES: i64 = 1024 * 1024; + +impl Declared { + /// Counts up to the first command that does not read; vrf-schema stops + /// on the same bytes, so the commands it applies are all counted. + fn read(mut reader: BitReader<'_>) -> Self { + let mut declared = Self::default(); + let _ = declared.read_commands(&mut reader); + declared + } + + fn read_commands(&mut self, reader: &mut BitReader<'_>) -> Result<(), vrf_bitio::BitError> { + let commands = reader.read_int_packed()?; + for _ in 0..commands { + let _path_name_index = reader.read_int_packed()?; + match reader.read_int_packed()? { + 0 => {} + 1 => { + let _path = reader.read_fstring(MAX_FSTRING_BYTES)?; + let slots = reader.read_int_packed()?; + self.groups += 1; + self.slots = self.slots.saturating_add(slots as usize); + } + // vrf-schema refuses the frame here. + _ => return Ok(()), + } + if reader.read_u8()? == 0 { + continue; + } + let _handle = reader.read_int_packed()?; + let _checksum = reader.read_u32()?; + // FName: a u8 kind, then an index, or a string and a number. + if reader.read_u8()? != 0 { + let _index = reader.read_int_packed()?; + } else { + let _name = reader.read_fstring(MAX_FSTRING_BYTES)?; + let _number = reader.read_i32()?; + } + } + Ok(()) + } +} + +/// The finished buffer: a JSON document and movement blob of these sizes. +pub fn check_output(json_bytes: usize, blob_bytes: usize) -> Result<(), ReplayError> { + if json_bytes.saturating_add(blob_bytes) > MAX_OUTPUT_BYTES { + return Err(exceeded("decoded replay bytes", MAX_OUTPUT_BYTES)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::frame::walk_demo_frames; + use vrf_testkit::{add_fstring, add_int_packed}; + + /// The slot count vrf-schema allows one group. + const GROUP_SLOTS: u32 = 65_536; + + /// An ExportData section declaring `groups` new groups of `slots` each, + /// one field exported on the first, and no export GUIDs. + fn exports(groups: u32, slots: u32) -> Vec { + let mut data = Vec::new(); + add_int_packed(&mut data, groups); + for index in 0..groups { + add_int_packed(&mut data, index); + add_int_packed(&mut data, 1); + add_fstring(&mut data, &format!("/Game/G{index}.G{index}_C")); + add_int_packed(&mut data, slots); + if index == 0 { + data.push(1); + add_int_packed(&mut data, 0); // handle + data.extend(0u32.to_le_bytes()); // checksum + data.push(0); // FName kind: a string + add_fstring(&mut data, "Health"); + data.extend(0i32.to_le_bytes()); + } else { + data.push(0); + } + } + add_int_packed(&mut data, 0); // export GUIDs + data + } + + fn admit(exports: &[u8], cache: &NetGuidCache) -> Result<(), ReplayError> { + admit_exports(&BitReader::new(exports), cache) + } + + #[test] + fn a_real_sized_schema_is_admitted() { + let data = exports(540, 20); + assert_eq!( + Declared::read(BitReader::new(&data)), + Declared { + groups: 540, + slots: 10_800 + } + ); + assert_eq!(admit(&data, &NetGuidCache::new()), Ok(())); + } + + #[test] + fn too_many_declared_slots_are_refused() { + let groups = (MAX_EXPORT_SLOTS as u32).div_ceil(GROUP_SLOTS) + 1; + let err = admit(&exports(groups, GROUP_SLOTS), &NetGuidCache::new()).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + assert!(err.message.contains("slots"), "{err}"); + } + + #[test] + fn slots_already_in_the_cache_count() { + let half = (MAX_EXPORT_SLOTS as u32 / GROUP_SLOTS).div_ceil(2) + 1; + let mut cache = NetGuidCache::new(); + let first = exports(half, GROUP_SLOTS); + assert_eq!(admit(&first, &cache), Ok(())); + let _ = + vrf_schema::read_net_field_exports(&mut BitReader::new(&first), &mut cache).unwrap(); + // The same paths again, so nothing about the second frame alone is + // too much. + let err = admit(&first, &cache).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } + + #[test] + fn too_many_groups_are_refused() { + let err = admit( + &exports(MAX_EXPORT_GROUPS as u32 + 1, 1), + &NetGuidCache::new(), + ) + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + assert!(err.message.contains("groups"), "{err}"); + } + + #[test] + fn too_many_guid_changes_are_refused() { + let mut cache = NetGuidCache::new(); + for i in 0..=MAX_GUID_CHANGES { + cache.set_net_guid_path(1, if i % 2 == 0 { "A" } else { "B" }, None); + } + let err = admit(&exports(0, 0), &cache).unwrap_err(); + assert!(err.message.contains("NetGUID"), "{err}"); + } + + /// One DemoFrame (no header flags) with `exports` and one 1-byte packet. + fn frame(exports: &[u8]) -> Vec { + let mut data = Vec::new(); + data.extend(0i32.to_le_bytes()); // currentLevelIndex + data.extend(1.0f32.to_le_bytes()); + data.extend(exports); + add_int_packed(&mut data, 0); // streaming levels + add_int_packed(&mut data, 0); // ExternalData ends + data.extend(1i32.to_le_bytes()); + data.push(7); + data.extend(0i32.to_le_bytes()); // packets end + data + } + + #[test] + fn the_walk_stops_before_the_cache_allocates_a_refused_schema() { + let groups = (MAX_EXPORT_SLOTS as u32).div_ceil(GROUP_SLOTS) + 1; + let data = frame(&exports(groups, GROUP_SLOTS)); + let mut cache = NetGuidCache::new(); + let mut refused = None; + let mut packets = 0; + walk_demo_frames( + &data, + 0, + &mut cache, + |exports, cache| { + refused = admit_exports(exports, cache).err(); + refused.is_none() + }, + |_, _| { + packets += 1; + true + }, + ) + .unwrap(); + assert!(refused.is_some()); + assert_eq!((cache.group_count(), packets), (0, 0)); + + // The same frame within the caps walks. + let mut packets = 0; + walk_demo_frames( + &frame(&exports(3, 10)), + 0, + &mut cache, + |exports, cache| admit_exports(exports, cache).is_ok(), + |_, _| { + packets += 1; + true + }, + ) + .unwrap(); + assert_eq!((cache.group_count(), packets), (3, 1)); + } + + #[test] + fn inflating_past_the_total_is_refused() { + let mut inflated = Inflated::default(); + let chunks = MAX_INFLATED_BYTES / MAX_CHUNK_BYTES as u64; + for _ in 0..chunks { + inflated.add(MAX_CHUNK_BYTES).unwrap(); + } + let err = inflated.add(1).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } + + #[test] + fn output_past_the_cap_is_refused() { + assert_eq!(check_output(MAX_OUTPUT_BYTES - 10, 10), Ok(())); + let err = check_output(MAX_OUTPUT_BYTES, 1).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt); + } +} diff --git a/native/replay/src/oodle.rs b/native/replay/src/oodle.rs index 519f54913..53aeaa8f3 100644 --- a/native/replay/src/oodle.rs +++ b/native/replay/src/oodle.rs @@ -10,6 +10,7 @@ use vrf_container::parse_replay_data_meta; use crate::error::{ErrorCode, ReplayError}; +use crate::limits; use crate::selkie::Selkie; /// Inflates one Oodle archive body (the bytes after its 8-byte header) to @@ -74,6 +75,12 @@ pub fn inflate_replay_data( let declared = usize::try_from(meta.size_in_bytes) .map_err(|_| corrupt(format!("negative ReplayData size {}", meta.size_in_bytes)))?; let memory = meta.memory_size_in_bytes as usize; // checked non-negative by vrf-container + if memory > limits::MAX_CHUNK_BYTES { + return Err(limits::exceeded( + "inflated ReplayData chunk bytes", + limits::MAX_CHUNK_BYTES, + )); + } if data.len() < declared { return Err(corrupt(format!( "ReplayData declares {declared} bytes, {} remain", @@ -252,6 +259,19 @@ mod tests { assert_eq!(plain, [7, 8, 9, 0]); } + #[test] + fn a_chunk_inflating_past_the_cap_is_refused_before_decoding() { + struct Never; + impl Decompressor for Never { + fn decompress(&self, _: &[u8], size: usize) -> Result, ReplayError> { + panic!("asked for {size} bytes"); + } + } + let memory = limits::MAX_CHUNK_BYTES as u32 + 1; + let err = inflate_replay_data(&replay_data(&[1, 2, 3], memory), true, &Never).unwrap_err(); + assert_eq!(err.code, ErrorCode::Corrupt, "{err}"); + } + #[test] fn a_short_codec_output_is_corrupt() { let err = inflate_replay_data(&replay_data(&[1], 4), true, &Fixed(vec![0; 3])).unwrap_err(); diff --git a/native/replay/src/vendor/frame.rs b/native/replay/src/vendor/frame.rs new file mode 100644 index 000000000..d9390b6e6 --- /dev/null +++ b/native/replay/src/vendor/frame.rs @@ -0,0 +1,204 @@ +// Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrf-frame/src/lib.rs and +// sections.rs. Apache-2.0; see native/replay/NOTICE.md. Local edits: +// `walk_demo_frames` takes a `before_exports` hook that sees each frame's +// ExportData before the cache applies it, both callbacks return `false` to +// stop the walk, and `FrameWalk` is a copy (vrf-frame's is non-exhaustive, so +// it cannot be built here). The wire-layout notes live in vrf-frame. + +//! DemoFrame iteration: decompressed replay-data chunk -> `(time_ms, packet)` +//! sequence. + +use vrf_bitio::BitReader; +use vrf_frame::{ + DemoPacket, FLAG_GAME_SPECIFIC_FRAME_DATA, FLAG_HAS_STREAMING_FIXES, FrameError, FrameSkips, +}; +use vrf_schema::NetGuidCache; + +/// Unreal's `MaxPacketSizeInBits`, in bytes. +const MAX_PACKET_SIZE_BYTES: i32 = 16384 / 8; + +/// Cap on a level name's FString. +const MAX_FSTRING_BYTES: i64 = 1024 * 1024; + +/// What one [`walk_demo_frames`] call walked. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FrameWalk { + /// Packets yielded to the callback. + pub packets: u32, + /// DemoFrames walked; not derivable from `packets`, since a frame carries + /// any number of packets. + pub frames: u32, + /// Section bytes stepped over without being decoded. + pub skipped: FrameSkips, + /// Frames whose `timeSeconds` was NaN or infinite. Their packets carry + /// 0 ms: a plausible wrong time, so it is counted. + pub non_finite_times: u32, +} + +/// Walk every DemoFrame in a decompressed ReplayData chunk (`data`), calling +/// `on_packet` for each packet. `flags` is `ReplayHeader.flags`. +/// +/// Each frame's ExportData is applied to `cache` before its packets, after +/// `before_exports` has seen it: a reader at the section's start and the +/// cache it is about to change. `on_packet` gets the frame's time, the +/// packet's bytes (a slice of `data`) and the cache as of that packet's wire +/// position. Either returning `false` stops the walk there. +pub fn walk_demo_frames( + data: &[u8], + flags: u32, + cache: &mut NetGuidCache, + mut before_exports: impl FnMut(&BitReader<'_>, &NetGuidCache) -> bool, + mut on_packet: impl FnMut(DemoPacket<'_>, &mut NetGuidCache) -> bool, +) -> Result { + let has_streaming_fixes = (flags & FLAG_HAS_STREAMING_FIXES) != 0; + let has_game_specific = (flags & FLAG_GAME_SPECIFIC_FRAME_DATA) != 0; + + let mut reader = BitReader::new(data); + let mut walk = FrameWalk::default(); + + while !reader.at_end() { + walk.frames += 1; + let _current_level_index = reader.read_i32()?; + let time_seconds = reader.read_f32()?; + // The consumer bundle's rule: `seconds * 1000` in f64, rounded half away + // from zero. Outside u32 ms after rounding is refused, not saturated + // (-1.0 s would read as the first frame); non-finite is 0 ms, counted. + let time_ms = if time_seconds.is_finite() { + let ms = (f64::from(time_seconds) * 1000.0).round(); + if ms < 0.0 || ms > f64::from(u32::MAX) { + return Err(FrameError::TimeOutOfRange { + seconds: time_seconds, + }); + } + ms as u32 + } else { + walk.non_finite_times += 1; + 0 + }; + + if !before_exports(&reader, cache) { + return Ok(walk); + } + // ExportData into `cache`; its per-frame counts reach no counter. + let _ = vrf_schema::read_net_field_exports(&mut reader, cache)?; + let _ = vrf_schema::read_export_guids(&mut reader, cache)?; + read_streaming_level_fixes(&mut reader, has_streaming_fixes)?; + + let (blobs, bytes) = read_external_data(&mut reader)?; + walk.skipped.external_data_blobs += blobs; + walk.skipped.external_data_bytes += bytes; + + walk.skipped.game_specific_bytes += + read_game_specific_frame_data(&mut reader, has_game_specific)?; + + loop { + if has_streaming_fixes { + let _seen_level_index = reader.read_int_packed()?; + } + + let packet_size = reader.read_i32()?; + if packet_size == 0 { + break; + } + if packet_size < 0 { + return Err(FrameError::NegativePacketSize { size: packet_size }); + } + if packet_size > MAX_PACKET_SIZE_BYTES { + return Err(FrameError::PacketTooLarge { + size: packet_size, + max: MAX_PACKET_SIZE_BYTES, + }); + } + + let packet_size_usize = packet_size as usize; + let bit_count = (packet_size_usize as u64) * 8; + if reader.bits_remaining() < bit_count { + return Err(FrameError::Truncated { + context: "packet data", + needed: packet_size_usize, + available: (reader.bits_remaining() / 8) as usize, + }); + } + + // Every frame read, ExportData included, is whole bytes. + debug_assert_eq!(reader.position() % 8, 0); + let byte_offset = (reader.position() / 8) as usize; + let packet_data = &data[byte_offset..byte_offset + packet_size_usize]; + reader.skip_bits(bit_count)?; + + let go_on = on_packet( + DemoPacket { + time_ms, + packet_index: walk.packets, + data: packet_data, + }, + cache, + ); + walk.packets += 1; + if !go_on { + return Ok(walk); + } + } + } + + Ok(walk) +} + +/// StreamingLevelFixes: level names, compact or verbose. +fn read_streaming_level_fixes( + reader: &mut BitReader<'_>, + has_streaming_fixes: bool, +) -> Result<(), FrameError> { + let num_levels = reader.read_int_packed()?; + + if has_streaming_fixes { + // Compact form: FString names, then a u64 externalOffset. + for _ in 0..num_levels { + let _ = reader.read_fstring(MAX_FSTRING_BYTES)?; + } + let _ = reader.read_u64()?; + } else { + // Verbose form, which no measured replay takes: packageName, + // packageNameToLoad and a 40-byte FTransform (10 f32) per entry. + for _ in 0..num_levels { + let _ = reader.read_fstring(MAX_FSTRING_BYTES)?; + let _ = reader.read_fstring(MAX_FSTRING_BYTES)?; + reader.skip_bits(40 * 8)?; + } + } + + Ok(()) +} + +/// ExternalData: numBits, netGuid and payload until numBits is 0. Returns the +/// `(blobs, bytes)` skipped. +fn read_external_data(reader: &mut BitReader<'_>) -> Result<(u64, u64), FrameError> { + let mut blobs = 0u64; + let mut bytes = 0u64; + loop { + let num_bits = reader.read_int_packed()?; + if num_bits == 0 { + return Ok((blobs, bytes)); + } + let _net_guid = reader.read_int_packed()?; + let byte_count = u64::from(num_bits.div_ceil(8)); + reader.skip_bits(byte_count * 8)?; + blobs += 1; + bytes += byte_count; + } +} + +/// GameSpecificFrameData: when the flag is set, a u64 byte count and that +/// many bytes. Returns the bytes skipped. +fn read_game_specific_frame_data( + reader: &mut BitReader<'_>, + has_game_specific: bool, +) -> Result { + if !has_game_specific { + return Ok(0); + } + let skip_offset = reader.read_u64()?; + // A raw wire u64: saturated, an overflow fails as Eof instead of wrapping. + reader.skip_bits(skip_offset.saturating_mul(8))?; + Ok(skip_offset) +} diff --git a/native/replay/src/vendor/mod.rs b/native/replay/src/vendor/mod.rs index 8344a8d3f..6041c279a 100644 --- a/native/replay/src/vendor/mod.rs +++ b/native/replay/src/vendor/mod.rs @@ -7,11 +7,15 @@ //! removed (we always want `ChannelState::players`). //! - `pass.rs`: `crates/vrfkit/src/pass.rs`, cut to the per-packet walk; the //! chunk loop, checkpoint scope and helper thread are ours (`crate::decode`). +//! - `frame.rs`: `crates/vrf-frame/src/lib.rs` and `sections.rs`'s +//! `walk_demo_frames`, with a hook ahead of each frame's ExportData (where +//! `crate::limits` budgets the schema) and callbacks that can stop the walk. //! //! Lints are relaxed here only: the vendored code builds under vrfkit's own //! toolchain policy, not ours. #![allow(dead_code, unused_imports, clippy::all, clippy::pedantic)] +pub mod frame; pub mod pass; pub mod sink; diff --git a/native/replay/src/vendor/pass.rs b/native/replay/src/vendor/pass.rs index deaeebff9..60315709c 100644 --- a/native/replay/src/vendor/pass.rs +++ b/native/replay/src/vendor/pass.rs @@ -1,15 +1,19 @@ // Vendored from vrfkit v0.2.5 (c4d6cc0), crates/vrfkit/src/pass.rs. Apache-2.0; // see native/replay/NOTICE.md. Local edits: `Replay`, `for_each_chunk` and the -// checkpoint block scope are dropped (crate::decode walks the chunks), and the -// CLI error type is replaced by `vrf_frame::FrameError`. +// checkpoint block scope are dropped (crate::decode walks the chunks), the +// CLI error type is replaced by `vrf_frame::FrameError`, and `walk` runs our +// vendored frame walker (`super::frame`) with its `admit` hook, stopping the +// moment either callback says so. //! The decode pass every subcommand runs: `validate`, `diag` and `export`, over //! the ReplayData stream and over each Checkpoint snapshot. -use vrf_frame::{FrameError, FrameSkips, walk_demo_frames}; +use vrf_bitio::BitReader; +use vrf_frame::{FrameError, FrameSkips}; use vrf_net::pipeline::ReplicationReader; use vrf_schema::NetGuidCache; +use super::frame::walk_demo_frames; use super::sink::{ChannelState, ExportSink, ExportStats, RecordBuffers}; /// One replication stream -- the ReplayData stream, or one checkpoint @@ -47,14 +51,16 @@ impl<'a> Pass<'a> { } /// Read every packet in `frames` through a fresh sink counting into - /// `stats`, then hand its rows to `drain`. Once `drain` returns `false` - /// later packets are skipped (the frame callback cannot stop the walk). + /// `stats`, then hand its rows to `drain`. `admit` sees each frame's + /// ExportData before the cache applies it. Once either returns `false` + /// the walk stops. pub fn walk( &mut self, frames: &[u8], stats: &mut ExportStats, + admit: impl FnMut(&BitReader<'_>, &NetGuidCache) -> bool, mut drain: impl FnMut(&mut RecordBuffers, &NetGuidCache) -> bool, - ) -> Result { + ) -> Result<(), FrameError> { let branch = self.branch; let Self { cache, @@ -64,11 +70,7 @@ impl<'a> Pass<'a> { packets, .. } = self; - let mut stopped = false; - let walk = walk_demo_frames(frames, self.flags, cache, |pkt, cache| { - if stopped { - return; - } + let walk = walk_demo_frames(frames, self.flags, cache, admit, |pkt, cache| { let mut packet = ExportSink::new(cache, channels, buffers); packet.enable_measured_array_routes(branch); packet.time_ms = pkt.time_ms; @@ -78,14 +80,12 @@ impl<'a> Pass<'a> { reader.process_packet(pkt.data, *packets as i32, &mut packet); std::mem::swap(&mut packet.stats, stats); *packets += 1; - if !drain(buffers, cache) { - stopped = true; - } + drain(buffers, cache) })?; self.frames += walk.frames; self.frame_skips.absorb(walk.skipped); self.non_finite_frame_times += u64::from(walk.non_finite_times); - Ok(!stopped) + Ok(()) } /// End of stream: every bunch still in partial reassembly is counted and diff --git a/native/replay/tests/corpus.rs b/native/replay/tests/corpus.rs index 1a97447e4..f224ecce1 100644 --- a/native/replay/tests/corpus.rs +++ b/native/replay/tests/corpus.rs @@ -1,6 +1,8 @@ -//! Corpus tests over real replays: set `ICARUS_REPLAY_CORPUS` to a directory -//! of `.vrf` files (skipped otherwise). Each decoded replay is checked -//! against its own Event chunks and against the rules of a match. +//! Corpus tests over real replays, a local check before each release: set +//! `ICARUS_REPLAY_CORPUS` to a directory of `.vrf` files (skipped otherwise). +//! Replays carry other players' account ids, so none is committed and CI +//! runs only the unit tests. Each decoded replay is checked against its own +//! Event chunks and against the rules of a match. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -13,7 +15,9 @@ use vrf_container::{ChunkIterator, ChunkType, parse_event_chunk, parse_preamble} fn corpus() -> Vec { let Some(dir) = std::env::var_os("ICARUS_REPLAY_CORPUS") else { - eprintln!("ICARUS_REPLAY_CORPUS unset: corpus test skipped"); + eprintln!( + "corpus test skipped: the corpus suite is a local pre-release check; set ICARUS_REPLAY_CORPUS to a folder of .vrf replays to run it" + ); return Vec::new(); }; let mut files: Vec = std::fs::read_dir(dir) diff --git a/test/replay_capture_test.dart b/test/replay_capture_test.dart index 43c202b61..97160599a 100644 --- a/test/replay_capture_test.dart +++ b/test/replay_capture_test.dart @@ -137,4 +137,27 @@ void main() { ); expect(box.isEmpty, isTrue); }); + + test('a utility turned by a non-finite angle is refused too', () async { + final spike = PlacedUtility( + id: 'replay-spike-0', + type: UtilityType.spike, + position: const Offset(500, 500), + )..rotation = double.infinity; + await expectLater( + capture().capture( + ReplayFrame( + timeMs: 0, + round: null, + isAttack: true, + agents: const [], + abilities: const [], + utilities: [spike], + ), + pageName: 'bad', + ), + throwsStateError, + ); + expect(box.isEmpty, isTrue); + }); } diff --git a/test/replay_files_test.dart b/test/replay_files_test.dart index 6f19c2ff5..62c26ff33 100644 --- a/test/replay_files_test.dart +++ b/test/replay_files_test.dart @@ -55,6 +55,17 @@ void main() { expect(names, ['c.vrf']); }); + test('importing a different file of the same name replaces the copy', + () async { + final first = await writeReplay(root, 'e'); + await files.import(first.path); + final other = await Directory(p.join(root.path, 'other')).create(); + final second = File(p.join(other.path, 'e.vrf')); + await second.writeAsBytes([9, 9, 9, 9, 9]); + final kept = await files.import(second.path); + expect(await File(kept.path).readAsBytes(), [9, 9, 9, 9, 9]); + }); + test('cache is keyed by file and decoder version, and replaces old ones', () async { await writeReplay(demos, 'd'); diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index 069a1f522..cb6fb51ae 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -22,6 +22,7 @@ ReplayDocument document({ Map vitals = const {}, List> utility = const [], Map>> movement = const {}, + Map firstRound = const {}, }) { final records = >[]; final movementJson = {}; @@ -41,7 +42,13 @@ ReplayDocument document({ {'subject': 'b', 'agentId': omen, 'team': 'Blue'}, ], 'rounds': [ - {'index': 0, 'startMs': 0, 'endMs': 60000, 'attackingTeam': 'Red'}, + { + 'index': 0, + 'startMs': 0, + 'endMs': 60000, + 'attackingTeam': 'Red', + ...firstRound, + }, {'index': 1, 'startMs': 60000, 'endMs': 120000, 'attackingTeam': 'Red'}, ], 'kills': kills, @@ -184,6 +191,37 @@ void main() { expect(blue.agents.single.isAlly, isFalse); }); + test('the spike is planted until it is defused', () { + final doc = document(firstRound: { + 'plant': { + 'timeMs': 20000, + 'position': [0, 0, 100], + }, + 'defuse': {'timeMs': 40000}, + }); + final frames = builder(doc); + int spikes(int t) => + frames.frameAt(t, perspective: ReplayTeam.red).utilities.length; + expect(spikes(19999), 0); + expect(spikes(30000), 1); + expect(spikes(40000), 0); + expect(spikes(50000), 0); + }); + + test('a player off the map (a Kill Contract duel) is not drawn', () { + final doc = document(movement: { + 'a': [ + [0, 0, 0, 100, 0, 0], + [500, 900000, 900000, 100, 0, 0], + ], + }); + final frames = builder(doc); + expect( + frames.frameAt(0, perspective: ReplayTeam.red).agents, hasLength(1)); + expect(frames.frameAt(500, perspective: ReplayTeam.red).agents, isEmpty); + expect(frames.playerState(player(doc, 'a'), 500).alive, isTrue); + }); + test('ownerless utility is drawn only when one player could own it', () { final smoke = { 'id': 1, diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 8ea9de975..22d969e03 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -93,6 +93,10 @@ install(TARGETS icarus_height RUNTIME DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) install(FILES "${ICARUS_REPLAY_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +install(FILES "${ICARUS_REPLAY_NOTICE}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + RENAME icarus_replay_NOTICE.md COMPONENT Runtime) +install(FILES "${ICARUS_REPLAY_LICENSE}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + RENAME icarus_replay_LICENSE.txt COMPONENT Runtime) install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" COMPONENT Runtime) From a3452aac1dd5a806551905d97cf12bf78b05e58d Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 02:27:27 -0400 Subject: [PATCH 09/18] Replace kept replays in one step; bump the decoder version A failed import no longer deletes the kept copy first: rename replaces it atomically. The decoder version moves to 0.2.0 so caches decoded by earlier builds of this change are rebuilt, and Cargo.toml says to bump it whenever the output can change. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/replay-format.md | 4 ++-- lib/replay/replay_files.dart | 2 +- native/replay/Cargo.lock | 2 +- native/replay/Cargo.toml | 5 ++++- native/replay/src/header.rs | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/replay-format.md b/docs/replay-format.md index 00927462a..bc085da19 100644 --- a/docs/replay-format.md +++ b/docs/replay-format.md @@ -41,7 +41,7 @@ first byte. ```jsonc { - "decoder": { "name": "icarus_replay", "version": "0.1.0+vrfkit.0.2.5", "vrfkit": "0.2.5" }, + "decoder": { "name": "icarus_replay", "version": "0.2.0+vrfkit.0.2.5", "vrfkit": "0.2.5" }, "match": { "id": "dc078274-2d68-495b-8321-5e58b2a3eeba", "mapPath": "/Game/Maps/Juliett/Juliett", @@ -257,7 +257,7 @@ the first). A clean replay has none. "durationMs": 321499, "recordedAt?": "2026-07-08T00:35:45Z", "supported": true, // a payload transform is registered for the build - "decoderVersion": "0.1.0+vrfkit.0.2.5", // same as the document's decoder.version + "decoderVersion": "0.2.0+vrfkit.0.2.5", // same as the document's decoder.version "players": [{ "subject": "…", "agentId": "…" }] } ``` diff --git a/lib/replay/replay_files.dart b/lib/replay/replay_files.dart index a77cbabd7..b5a9e1723 100644 --- a/lib/replay/replay_files.dart +++ b/lib/replay/replay_files.dart @@ -164,7 +164,7 @@ class ReplayFiles { final partial = _partial(destination); try { await source.copy(partial.path); - if (await destination.exists()) await destination.delete(); + // Replaces a kept copy in one step, so a failure leaves it intact. await partial.rename(destination.path); } finally { if (await partial.exists()) await partial.delete(); diff --git a/native/replay/Cargo.lock b/native/replay/Cargo.lock index 3d05f19ac..18b20e5e8 100644 --- a/native/replay/Cargo.lock +++ b/native/replay/Cargo.lock @@ -58,7 +58,7 @@ dependencies = [ [[package]] name = "icarus_replay" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/native/replay/Cargo.toml b/native/replay/Cargo.toml index 451a03d28..67e39dd9e 100644 --- a/native/replay/Cargo.toml +++ b/native/replay/Cargo.toml @@ -1,6 +1,9 @@ [package] name = "icarus_replay" -version = "0.1.0" +# Bump whenever the decoded output could change: Icarus keys its cache of +# decoded replays by it (header::DECODER_VERSION), so an unchanged version +# serves the old output. +version = "0.2.0" edition = "2024" rust-version = "1.86" license = "Apache-2.0" diff --git a/native/replay/src/header.rs b/native/replay/src/header.rs index 2e8917910..0b8178392 100644 --- a/native/replay/src/header.rs +++ b/native/replay/src/header.rs @@ -188,7 +188,7 @@ mod tests { #[test] fn decoder_version_names_both_versions() { - assert_eq!(DECODER_VERSION, "0.1.0+vrfkit.0.2.5"); + assert_eq!(DECODER_VERSION, "0.2.0+vrfkit.0.2.5"); } #[test] From b80af76e6ab058043023cdfe75447f69ef26d24f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 18:58:07 -0400 Subject: [PATCH 10/18] Replay: smokes block sight, utility arrives and leaves, playback at 165 Hz Dara's notes from testing the viewer: - Smokes block view cones. Every smoke (Omen, Brimstone, Clove, Jett, Miks, Astra, Viper's orb, Harbor's Cove, Cypher's cage) hides itself and the shadow behind it; Viper's Toxic Screen, Phoenix's Blaze and Harbor's High Tide hide what lies behind their line. Standing in a smoke, nothing shows. - Thrown utility is shown in flight: its icon along the replay's path with a short trail (Omen and Jett smoke orbs, mollies, Sova's bolts, Fade's Seize, Reyna's Leer, Curveball and others), and a ring spreads where it activates. - Utility that ends (destroyed, expired or popped) fades out where it stood with a burst, rather than vanishing. The replay does not say which. - Ranges and areas players stand in are drawn at 30% so the agents and cones under them stay readable; smokes stay solid. - Playback is smooth. Players moved a few at a time before (the cone cut was the cost); now every player moves every frame, moving utility glides between its 10 Hz path points, and cones are cut on a worker isolate (ReplayConeWorker) and drawn from their latest cut carried along with the agent, all in one layer clipped to the floor once (ReplayConesPainter). The roster, killfeed, dock and match card rebuild when what they show changes, and utility sits on its own cached layer. Measured with the profile playback harness on Split: UI thread 3.0 ms and raster 3.9-5.7 ms a frame (the window runs at its 144 Hz cap), from 13.5 ms UI and 4.6 ms raster at 73 fps before. The editor's view cone is unchanged apart from a hook that lets the replay draw cones itself, and the height model's cone query is split so the horizontal cut, which needs no dart:ui paths, can run off the root isolate. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/replay/replay_ability_catalog.dart | 94 +++++-- lib/replay/replay_cone_cuts.dart | 152 +++++++++++ lib/replay/replay_cone_worker.dart | 202 ++++++++++++++ lib/replay/replay_document.dart | 37 ++- lib/replay/replay_frame.dart | 258 ++++++++++++++++-- lib/replay/replay_playback.dart | 54 ++-- lib/replay_view.dart | 53 ++++ lib/view_cone/svg_height_visibility.dart | 42 ++- lib/view_cone/vision_occluders.dart | 166 +++++++++++ .../utilities/svg_height_view_cone.dart | 19 ++ lib/widgets/replay/replay_canvas.dart | 171 +++++++++++- lib/widgets/replay/replay_cones.dart | 167 ++++++++++++ lib/widgets/replay/replay_dock.dart | 66 +++-- lib/widgets/replay/replay_effects.dart | 136 +++++++++ lib/widgets/replay/replay_match_card.dart | 16 +- lib/widgets/replay/replay_roster.dart | 27 +- lib/widgets/replay/replay_select.dart | 69 +++++ test/replay_ability_catalog_test.dart | 63 +++-- test/replay_frame_test.dart | 175 ++++++++++-- test/vision_occluders_test.dart | 71 +++++ 20 files changed, 1858 insertions(+), 180 deletions(-) create mode 100644 lib/replay/replay_cone_cuts.dart create mode 100644 lib/replay/replay_cone_worker.dart create mode 100644 lib/view_cone/vision_occluders.dart create mode 100644 lib/widgets/replay/replay_cones.dart create mode 100644 lib/widgets/replay/replay_effects.dart create mode 100644 lib/widgets/replay/replay_select.dart create mode 100644 test/vision_occluders_test.dart diff --git a/lib/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart index 8f6cfd516..9e0f08bfd 100644 --- a/lib/replay/replay_ability_catalog.dart +++ b/lib/replay/replay_ability_catalog.dart @@ -13,8 +13,8 @@ import 'package:icarus/widgets/draggable_widgets/ability/deadlock_barrier_mesh_w /// Which Icarus ability a replay utility actor is, and how to place it. /// -/// Null means the actor is not drawn: the projectile of an ability whose -/// lasting effect is its own actor, a cosmetic, or game bookkeeping. +/// Null means the actor is not drawn: a cosmetic, game bookkeeping, or a +/// piece of an ability another actor already draws. ReplayAbilityEntry? replayAbilityFor(String classPath) => _catalog[replayAssetName(classPath)]; @@ -52,6 +52,21 @@ enum ReplayUtilityKind { /// Deadlock's Barrier Mesh: arms out from a centre. barrierMesh, + + /// A thrown utility on its way to becoming another actor (a smoke orb, a + /// molly, a recon bolt). Drawn as its ability's icon along its path, not + /// as the ability, which belongs to where it lands. + flight, +} + +/// How a utility blocks sight while it is up, beyond the map's walls. +enum ReplayVisionBlock { + /// A smoke, as wide as the ability draws it. + sphere, + + /// A wall of smoke or fire along the utility's shape points, or along its + /// path when it has none (Harbor's High Tide is where its head went). + line, } class ReplayAbilityEntry { @@ -59,8 +74,9 @@ class ReplayAbilityEntry { this.agent, this.abilityIndex, this.kind, - this._placement, - ); + this._placement, { + this.blocks, + }); final AgentType agent; @@ -71,6 +87,9 @@ class ReplayAbilityEntry { final ReplayUtilityKind kind; final _Placement _placement; + /// How it blocks sight, when it does. + final ReplayVisionBlock? blocks; + AbilityInfo get ability => AgentData.agents[agent]!.abilities[abilityIndex]; /// The Icarus ability as it stands at [timeMs] (projectiles follow @@ -309,6 +328,11 @@ const _fromStart = _FromStart(); ReplayAbilityEntry _area(AgentType agent, int index) => ReplayAbilityEntry._(agent, index, ReplayUtilityKind.area, _point); +ReplayAbilityEntry _smoke(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.area, _point, + blocks: ReplayVisionBlock.sphere); +ReplayAbilityEntry _flight(AgentType agent, int index) => + ReplayAbilityEntry._(agent, index, ReplayUtilityKind.flight, _point); ReplayAbilityEntry _marker(AgentType agent, int index) => ReplayAbilityEntry._(agent, index, ReplayUtilityKind.marker, _point); ReplayAbilityEntry _projectile(AgentType agent, int index) => @@ -329,21 +353,24 @@ final Map _catalog = { // Astra (Rift), asset index. Astra Star (index 4) has no actor we know of. 'GameObject_Rift_4_BlackHole': _area(AgentType.astra, 0), 'GameObject_Rift_Q_FlashBurst': _area(AgentType.astra, 1), - 'GameObject_Rift_E_SmokeZone': _area(AgentType.astra, 2), - 'GameObject_Rift_E_SmokeZone_Fake': _area(AgentType.astra, 2), + 'GameObject_Rift_E_SmokeZone': _smoke(AgentType.astra, 2), + 'GameObject_Rift_E_SmokeZone_Fake': _smoke(AgentType.astra, 2), 'GameObject_Rift_X_GlobalWall': const ReplayAbilityEntry._( AgentType.astra, 3, ReplayUtilityKind.wall, _facing), // Breach. Rolling Thunder from the asset index. 'GameObject_Breach_4_FusionBlast': _rectangle(AgentType.breach, 0), + 'Projectile_Breach_4_FusionBlast': _flight(AgentType.breach, 0), 'Projectile_Breach_Q_ThroughWalls_Flash': _projectile(AgentType.breach, 1), 'GameObject_Breach_E_SweetSpotFissure': _rectangle(AgentType.breach, 2), 'GameObject_Breach_X_Shockwave': _rectangle(AgentType.breach, 3), // Brimstone (Sarge). 'GameObject_Sarge_E_SpeedStim': _area(AgentType.brimstone, 0), + 'Projectile_Sarge_E_SpeedStim': _flight(AgentType.brimstone, 0), 'Patch_Sarge_Q_Molotov_Production': _area(AgentType.brimstone, 1), - 'GameObject_Sarge_4_Smoke_ProductionNEW': _area(AgentType.brimstone, 2), + 'Projectile_Sarge_Q_Molotov_Production': _flight(AgentType.brimstone, 1), + 'GameObject_Sarge_4_Smoke_ProductionNEW': _smoke(AgentType.brimstone, 2), 'GameObject_Sarge_X_OrbitalStrike_Production': _area(AgentType.brimstone, 3), // Chamber (Deadeye). Headhunter and Tour De Force are guns. @@ -353,8 +380,9 @@ final Map _catalog = { // Clove (Smonk). Pick-me-up and Not Dead Yet have no actor. 'GameObject_Smonk_Q_DecayExplosion': _area(AgentType.clove, 1), - 'GameObject_Smonk_NewSmoke': _area(AgentType.clove, 2), - 'GameObject_Smonk_NewSmoke_PDS': _area(AgentType.clove, 2), + 'Projectile_Smonk_DecayNade': _flight(AgentType.clove, 1), + 'GameObject_Smonk_NewSmoke': _smoke(AgentType.clove, 2), + 'GameObject_Smonk_NewSmoke_PDS': _smoke(AgentType.clove, 2), // Cypher (Gumshoe). The Trapwire actor sits on its first anchor, its yaw // toward the second, which is the _SecondWire actor's position. Given @@ -362,7 +390,7 @@ final Map _catalog = { // yaw at full length. The thrown cage never replicates where it lands // (its actor stays on Cypher), so only the cage zone is drawn. 'GameObject_Gumshoe_E_TripWire': _wall(AgentType.cypher, 0), - 'Zone_Gumshoe_4_Cage': _area(AgentType.cypher, 1), + 'Zone_Gumshoe_4_Cage': _smoke(AgentType.cypher, 1), 'Pawn_Gumshoe_Q_PossessableCamera': _marker(AgentType.cypher, 2), 'GameObject_RemovableObject_GumshoeTrackingDart': _marker(AgentType.cypher, 2), @@ -372,6 +400,8 @@ final Map _catalog = { // Sonic Sensor's yaw faces out of the surface it is stuck to. Annihilation's // own actor stays on Deadlock; the cocoon is where it caught someone. 'Patch_NetToss': _area(AgentType.deadlock, 0), + 'Projectile_NetToss': _flight(AgentType.deadlock, 0), + 'Projectile_CableJam_InAir': _flight(AgentType.deadlock, 2), 'GameObject_StealthingTrap_SoundSensor': _rectangle(AgentType.deadlock, 1), 'GameObject_SoundSensor_SweetSpotFissure': _rectangle(AgentType.deadlock, 1), 'GameObject_CableJamRoot': const ReplayAbilityEntry._( @@ -381,6 +411,8 @@ final Map _catalog = { // Fade (BountyHunter). 'Pawn_BountyHunter_4_WolfHound': _marker(AgentType.fade, 0), 'GameObject_Q_BountyHunter_Tether_SphereExpansion': _area(AgentType.fade, 1), + 'Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion': + _flight(AgentType.fade, 1), 'Projectile_E_BountyHunter_Divebomb': _projectile(AgentType.fade, 2), 'GameObject_BountyHunter_E_LoSReveal_Source_Reactivate': _area(AgentType.fade, 2), @@ -389,6 +421,7 @@ final Map _catalog = { // Gekko (AggroBot). Reclaim globules are left out: placing one as its // ability would draw Mosh Pit's circle where no Mosh Pit is. 'Patch_Aggrobot_C_ExplodeyPatch': _area(AgentType.gekko, 0), + 'Projectile_Aggrobot_C_ExplodeyPatch': _flight(AgentType.gekko, 0), 'Pawn_Aggrobot_SeekerNade': _marker(AgentType.gekko, 1), 'Projectile_E_Aggrobot_DiscTurret_PowerWave': _projectile(AgentType.gekko, 2), 'Pawn_Aggrobot_RollyPolly': _marker(AgentType.gekko, 3), @@ -396,8 +429,10 @@ final Map _catalog = { // Harbor (Mage), asset index. Icarus draws High Tide as its icon, so it // follows the wall's guided head (Giehl's MageWallDescriptor). 'GameObject_Mage_4_SplashGrenade': _area(AgentType.harbor, 0), - 'Projectile_Mage_Q_Wall': _projectile(AgentType.harbor, 1), - 'GameObject_Mage_E_WorldSmoke': _area(AgentType.harbor, 2), + 'Projectile_Mage_Q_Wall': const ReplayAbilityEntry._( + AgentType.harbor, 1, ReplayUtilityKind.projectile, _point, + blocks: ReplayVisionBlock.line), + 'GameObject_Mage_E_WorldSmoke': _smoke(AgentType.harbor, 2), 'GameObject_Mage_X_TidalWave': _rectangle(AgentType.harbor, 3), // Iso (Sequoia). @@ -409,7 +444,8 @@ final Map _catalog = { 'GameObject_Sequoia_X_LineCapture': _rectangle(AgentType.iso, 3), // Jett (Wushu). Updraft, Tailwind and Blade Storm have no lasting actor. - 'GameObject_Wushu_4_SmokeZone': _area(AgentType.jett, 0), + 'GameObject_Wushu_4_SmokeZone': _smoke(AgentType.jett, 0), + 'Projectile_Wushu_4_Smoke': _flight(AgentType.jett, 0), // KAY/O (Grenadier). FRAG/ment and NULL/cmd from the asset index. 'Projectile_Grenadier_Q_SemtexBasic': _projectile(AgentType.kayo, 0), @@ -430,8 +466,10 @@ final Map _catalog = { // Harmonize (by icon), though agents.dart names them the other way. // Harmonize is a buff with no actor. 'GameObject_Thumper_Concuss': _area(AgentType.miks, 0), + 'Projectile_Thumper_Concuss': _flight(AgentType.miks, 0), + 'Projectile_Thumper_Heal': _flight(AgentType.miks, 1), 'GameObject_Thumper_Heal': _area(AgentType.miks, 1), - 'GameObject_Iris_E_Smoke': _area(AgentType.miks, 2), + 'GameObject_Iris_E_Smoke': _smoke(AgentType.miks, 2), 'GameObject_Iris_X_SonicWave': const ReplayAbilityEntry._( AgentType.miks, 4, ReplayUtilityKind.area, _facing), @@ -442,16 +480,23 @@ final Map _catalog = { // Omen (Wraith). Shrouded Step has no actor. 'Projectile_Wraith_Q_NearsightMissile': const ReplayAbilityEntry._( AgentType.omen, 1, ReplayUtilityKind.projectile, _fromStart), - 'Zone_Wraith_4_Smoke': _area(AgentType.omen, 2), + 'Zone_Wraith_4_Smoke': _smoke(AgentType.omen, 2), + 'Projectile_Wraith_4_Smoke': _flight(AgentType.omen, 2), 'Intention_Wraith_X_GlobalTeleport': _marker(AgentType.omen, 3), // Phoenix. Icarus index 1 is Curveball and index 2 Hot Hands (by icon and - // shape), though agents.dart names them the other way. Curveball's actors - // stay on Phoenix and never replicate the flare's flight, so it is not - // drawn. + // shape), though agents.dart names them the other way. Curveball's flare + // replicates its flight as its own projectile, left or right. 'GameObject_Phoenix_Q_FlameWallManager_Production': - _wall(AgentType.pheonix, 0), + const ReplayAbilityEntry._( + AgentType.pheonix, 0, ReplayUtilityKind.wall, _fromStart, + blocks: ReplayVisionBlock.line), 'Patch_Phoenix_MolotovFire': _area(AgentType.pheonix, 2), + 'Projectile_Phoenix_E_FlareCurve_Synced': _projectile(AgentType.pheonix, 1), + 'Projectile_Phoenix_E_FlareCurve_Synced_Right': + _projectile(AgentType.pheonix, 1), + 'Projectile_Phoenix_4_Molotov_Production': _flight(AgentType.pheonix, 2), + 'Projectile_Phoenix_Q_FlameWall_ThroughWall': _flight(AgentType.pheonix, 0), 'GameObject_Phoenix_X_ResTarget_Production': _marker(AgentType.pheonix, 3), // Raze (Clay). @@ -463,12 +508,14 @@ final Map _catalog = { // Reyna (Vampire). Devour and Dismiss spend soul orbs, which come from // kills, not from an ability; Empress has no actor. 'GameObject_Vampire_4_NearsightAOE_Source': _marker(AgentType.reyna, 0), + 'Projectile_Vampire_4_NearsightAoE': _flight(AgentType.reyna, 0), // Sage (Thorne). The wall's actor is its centre and the wall runs across // its yaw. Healing Orb and Resurrection have no actor. 'GameObject_Thorne_E_Wall_Fortifying': const ReplayAbilityEntry._( AgentType.sage, 0, ReplayUtilityKind.wall, _AtPoint(yawOffset: 90)), 'Patch_Thorne_4_SlowField_Production': _area(AgentType.sage, 1), + 'Projectile_Thorne_4_SlowFIeld_Production': _flight(AgentType.sage, 1), // Skye (Guide). 'GameObject_Guide_4_Heal_AOE': _area(AgentType.skye, 0), @@ -482,6 +529,8 @@ final Map _catalog = { 'GameObject_Hunter_E_Drone_RevealDart': _marker(AgentType.sova, 0), 'GameObject_Hunter_4_ExplosiveBolt_Explosion': _area(AgentType.sova, 1), 'GameObject_Hunter_Q_SonarBolt': _area(AgentType.sova, 2), + 'Projectile_Hunter_4_ExplosiveBolt': _flight(AgentType.sova, 1), + 'Projectile_Hunter_Q_RevealBolt': _flight(AgentType.sova, 2), // Tejo (Cashew). Explosions from the asset index. 'Pawn_Cashew_4_Spider_LockOn': _marker(AgentType.tejo, 0), @@ -504,8 +553,10 @@ final Map _catalog = { // Viper (Pandemic), asset index. Toxic Screen's points come from its // manager's MulticastAddSmokeScreenPoint (Giehl's SmokeScreenManager). 'Patch_Pandemic_AcidMolotov_NewMolotov': _area(AgentType.viper, 0), - 'GameObject_Pandemic_4_SmokeZone': _area(AgentType.viper, 1), - 'GameObject_Pandemic_E_SmokeScreenManager': _wall(AgentType.viper, 2), + 'GameObject_Pandemic_4_SmokeZone': _smoke(AgentType.viper, 1), + 'GameObject_Pandemic_E_SmokeScreenManager': const ReplayAbilityEntry._( + AgentType.viper, 2, ReplayUtilityKind.wall, _fromStart, + blocks: ReplayVisionBlock.line), 'Patch_Pandemic_X_Circular': _area(AgentType.viper, 3), // Vyse (Nox). Icarus index 0 draws Shear and index 1 Razorvine (by icon @@ -516,6 +567,7 @@ final Map _catalog = { AgentType.vyse, 0, ReplayUtilityKind.wall, _FromStart(yawOffset: -90)), 'GameObject_Nox_WallTrap': _wall(AgentType.vyse, 0), 'GameObject_Nox_BarbedWire': _area(AgentType.vyse, 1), + 'Projectile_Nox_BarbedWire': _flight(AgentType.vyse, 1), 'Patch_Nox_BarbedWire': _area(AgentType.vyse, 1), 'GameObject_Nox_StealthingTrap_Flash_2': _marker(AgentType.vyse, 2), 'Gameobject_Nox_DisarmPulse': _area(AgentType.vyse, 3), diff --git a/lib/replay/replay_cone_cuts.dart b/lib/replay/replay_cone_cuts.dart new file mode 100644 index 000000000..1850d1629 --- /dev/null +++ b/lib/replay/replay_cone_cuts.dart @@ -0,0 +1,152 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/replay/replay_cone_worker.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; + +/// Where a player's view cone is aimed: everything its cut depends on. +@immutable +class ReplayConeAim { + const ReplayConeAim({ + required this.origin, + required this.rotation, + required this.isAttack, + this.elevationCm, + }); + + /// Canonical world units, where the cone's apex is drawn. + final Offset origin; + + /// Canonical rotation, as a placed view cone stores it. + final double rotation; + final bool isAttack; + final double? elevationCm; + + @override + bool operator ==(Object other) => + other is ReplayConeAim && + other.origin == origin && + other.rotation == rotation && + other.isAttack == isAttack && + other.elevationCm == elevationCm; + + @override + int get hashCode => Object.hash(origin, rotation, isAttack, elevationCm); +} + +/// A finished cut: the [aim] it was made for, where the cone stands in +/// height-model space, and what it sees. Null [cone] means there is +/// nowhere to stand nearby. +class ReplayConeCut { + const ReplayConeCut(this.id, this.aim, this.origin, this.cone); + + /// Different for every cut, so a cone knows when to redraw. + final int id; + final ReplayConeAim aim; + final Offset? origin; + final SvgVisibilityCone? cone; +} + +/// The latest cut of each player's view cone, kept fresh by a +/// [ReplayConeWorker] while the UI thread draws. Each player has at most one +/// cut on its way; [want] asks for another once the player has moved. Cones +/// are drawn from their latest cut, carried along with their agent, so the +/// worker's pace sets how often walls are recut, never the frame rate. +class ReplayConeCuts { + ReplayConeCuts({ + required this.worker, + required this.map, + required this.attackModel, + required this.defenseModel, + required this.coneLength, + required this.apertureDegrees, + required this.onCut, + }); + + final ReplayConeWorker worker; + final MapValue map; + + /// For the reviewed-floor pass, which only the root isolate can run. + final SvgHeightVisibility attackModel, defenseModel; + + /// The cones' length and spread, as the editor's view cones take them. + final double coneLength, apertureDegrees; + + /// Called when a cut arrives, so a paused frame can show it. + final VoidCallback onCut; + + final _latest = {}; + final _waiting = {}; + var _nextId = 0; + var _disposed = false; + + ReplayConeCut? latest(String subject) => _latest[subject]; + + /// Asks for [subject]'s cone at [aim], unless its latest cut is already + /// there or another is on its way. + void want(String subject, ReplayConeAim aim) { + if (_waiting.contains(subject) || _latest[subject]?.aim == aim) return; + _waiting.add(subject); + worker.cut(_request(aim)).then((result) { + _waiting.remove(subject); + if (_disposed) return; + _latest[subject] = ReplayConeCut( + _nextId++, + aim, + result.origin, + _withFloors(aim, result), + ); + onCut(); + }, onError: (Object _) { + _waiting.remove(subject); + }); + } + + /// The request the cone widget would make for [aim] itself. + ReplayConeRequest _request(ReplayConeAim aim) { + final coordinates = CoordinateSystem.instance; + final transform = SvgHeightMapTransform.forMap(map); + final side = coordinates.positionForSide( + canonicalPosition: aim.origin, + reflectionOffset: Offset.zero, + isAttack: aim.isAttack, + ); + final rotation = + coordinates.rotationForSide(aim.rotation, isAttack: aim.isAttack); + return ReplayConeRequest( + isAttack: aim.isAttack, + origin: transform.sourceFromSideWorld(side, isAttack: aim.isAttack), + directionRadians: rotation - math.pi / 2, + range: + CoordinateSystem.virtualLengthInWorld(coneLength) / transform.scale, + apertureRadians: apertureDegrees * math.pi / 180, + elevationCm: aim.elevationCm, + ); + } + + SvgVisibilityCone? _withFloors(ReplayConeAim aim, ReplayConeResult result) { + final cone = result.cone; + final origin = result.origin; + final model = aim.isAttack ? attackModel : defenseModel; + if (cone == null || origin == null || !model.hasSightlineFloors) { + return cone; + } + final request = _request(aim); + return model.withSightlineFloors( + cone, + origin: origin, + directionRadians: request.directionRadians, + range: request.range, + apertureRadians: request.apertureRadians, + ); + } + + void dispose() { + _disposed = true; + worker.dispose(); + } +} diff --git a/lib/replay/replay_cone_worker.dart b/lib/replay/replay_cone_worker.dart new file mode 100644 index 000000000..b72420b78 --- /dev/null +++ b/lib/replay/replay_cone_worker.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/view_cone/vision_world_gzip.dart'; + +/// One view cone to cut, in a side's height-model space. +class ReplayConeRequest { + const ReplayConeRequest({ + required this.isAttack, + required this.origin, + required this.directionRadians, + required this.range, + required this.apertureRadians, + this.elevationCm, + }); + + final bool isAttack; + + /// Where the agent stands; the worker moves it out of wall ink first, as + /// the cone widget does. + final Offset origin; + final double directionRadians, range, apertureRadians; + + /// The saved eye height choosing the level, as on a placed agent. + final double? elevationCm; +} + +/// A cut cone: where it stands (after any nudge out of wall ink) and what +/// it sees there by a horizontal sightline. Null [origin] means there is +/// nowhere to stand nearby and no cone to draw. +class ReplayConeResult { + const ReplayConeResult(this.origin, this.cone); + + final Offset? origin; + final SvgVisibilityCone? cone; +} + +/// Cuts view cones against a map's walls on a worker isolate, so replay +/// playback keeps the UI thread for drawing. The worker holds its own copy +/// of both sides' height models (and its own native acceleration); it +/// answers in the order asked. +/// +/// The cut is [SvgHeightVisibility.horizontalCone]. Where a map overlooks +/// reviewed floors (`withSightlineFloors`), that pass uses `dart:ui` paths +/// and is left to the caller on the root isolate. +class ReplayConeWorker { + ReplayConeWorker._(this._isolate, this._requests, this._responses); + + final Isolate _isolate; + final SendPort _requests; + final ReceivePort _responses; + final _pending = >{}; + var _nextTicket = 0; + var _closed = false; + + /// Starts a worker on the gzip-compressed height models of a map's two + /// sides, as bundled. + static Future start({ + required Uint8List attackModel, + required Uint8List defenseModel, + }) async { + final responses = ReceivePort(); + final ready = Completer(); + late final ReplayConeWorker worker; + responses.listen((message) { + if (message is SendPort) { + ready.complete(message); + } else if (message is List && message.length == 2) { + worker._answer(message[0] as int, message[1]); + } else if (message is List && message.length == 3) { + // [error, stack, ticket]: a bad request, not a dead worker. + worker._fail(message[2] as int?, message[0] as String); + } + }); + final isolate = await Isolate.spawn( + _workerMain, + ( + responses.sendPort, + TransferableTypedData.fromList([attackModel]), + TransferableTypedData.fromList([defenseModel]), + ), + errorsAreFatal: false, + debugName: 'replay cone worker', + ); + worker = ReplayConeWorker._(isolate, await ready.future, responses); + return worker; + } + + /// Cuts [request]'s cone. + Future cut(ReplayConeRequest request) { + if (_closed) return Future.error(StateError('Cone worker is closed.')); + final ticket = _nextTicket++; + final completer = Completer(); + _pending[ticket] = completer; + _requests.send(Float64List.fromList([ + ticket.toDouble(), + request.isAttack ? 1 : 0, + request.origin.dx, + request.origin.dy, + request.directionRadians, + request.range, + request.apertureRadians, + request.elevationCm ?? double.nan, + ])); + return completer.future; + } + + void _answer(int ticket, Object? payload) { + final completer = _pending.remove(ticket); + if (completer == null) return; + if (payload is! Float64List) { + completer.complete(const ReplayConeResult(null, null)); + return; + } + // [originX, originY, eyeAboveFloor, eyeElevation, x0, y0, x1, y1, ...] + final polygon = [ + for (var i = 4; i + 1 < payload.length; i += 2) + Offset(payload[i], payload[i + 1]), + ]; + completer.complete(ReplayConeResult( + Offset(payload[0], payload[1]), + SvgVisibilityCone( + polygon, + payload[2], + const SvgVisibilityStats(0, 0, 0, 0, 0), + eyeElevationMeters: payload[3].isNaN ? null : payload[3], + ), + )); + } + + void _fail(int? ticket, String error) { + final completer = ticket == null ? null : _pending.remove(ticket); + completer?.completeError(StateError(error)); + } + + void dispose() { + if (_closed) return; + _closed = true; + _isolate.kill(priority: Isolate.immediate); + _responses.close(); + for (final completer in _pending.values) { + completer.completeError(StateError('Cone worker is closed.')); + } + _pending.clear(); + } +} + +SvgHeightVisibility _model(TransferableTypedData bytes) { + final model = SvgHeightVisibility.fromJson(jsonDecode( + utf8.decode(decodeWorldGzip(bytes.materialize().asUint8List()))) + as Map); + model.enableNativeAcceleration(); + return model; +} + +void _workerMain( + (SendPort, TransferableTypedData, TransferableTypedData) setup) { + final (replies, attackBytes, defenseBytes) = setup; + final attack = _model(attackBytes); + final defense = _model(defenseBytes); + final requests = ReceivePort(); + replies.send(requests.sendPort); + requests.listen((message) { + final request = message as Float64List; + final ticket = request[0].toInt(); + try { + final model = request[1] == 1 ? attack : defense; + final standing = model.standablePointNear(Offset(request[2], request[3])); + if (standing == null) { + replies.send([ticket, null]); + return; + } + final elevation = request[7]; + final support = model.standingSupportAt(standing, + savedEyeElevationCm: elevation.isNaN ? null : elevation); + final cone = model.horizontalCone( + origin: standing, + directionRadians: request[4], + range: request[5], + apertureRadians: request[6], + supportId: support?.id, + ); + final polygon = cone.polygon; + final payload = Float64List(4 + polygon.length * 2) + ..[0] = standing.dx + ..[1] = standing.dy + ..[2] = cone.eyeHeightAboveFloorMeters + ..[3] = cone.eyeElevationMeters ?? double.nan; + for (var i = 0; i < polygon.length; i++) { + payload[4 + i * 2] = polygon[i].dx; + payload[5 + i * 2] = polygon[i].dy; + } + replies.send([ticket, payload]); + } catch (error, stack) { + replies.send(['$error', '$stack', ticket]); + } + }); +} diff --git a/lib/replay/replay_document.dart b/lib/replay/replay_document.dart index c1ee9a763..d014963b1 100644 --- a/lib/replay/replay_document.dart +++ b/lib/replay/replay_document.dart @@ -309,6 +309,7 @@ class ReplayEconomy { }); final String subject; + /// Null when no money was replicated by the end of the buy phase. final int? credits; @@ -382,17 +383,34 @@ class ReplayUtility { bool isActiveAt(int timeMs) => timeMs >= spawnMs && (endMs == null || timeMs < endMs!); - /// Where the utility is at [timeMs]: the last path point reached, else - /// where it spawned. + /// Where the utility is at [timeMs], between the path points around it + /// (the path is about 10 Hz, so stepping from point to point stutters). + /// Before its first point it is where it spawned. ReplayVector positionAt(int timeMs) { - var current = position; + var fromMs = spawnMs; + var from = position; for (final point in path) { - if (point.timeMs > timeMs) break; - current = point.position; + if (point.timeMs > timeMs) { + final span = point.timeMs - fromMs; + if (span <= 0) return point.position; + final t = ((timeMs - fromMs) / span).clamp(0.0, 1.0); + return from.lerp(point.position, t); + } + fromMs = point.timeMs; + from = point.position; } - return current; + return from; } + /// Where the utility went from [fromMs] to [toMs], ending where it is at + /// [toMs]: the path points between, in order. + List trail(int fromMs, int toMs) => [ + if (fromMs <= spawnMs) position, + for (final point in path) + if (point.timeMs > fromMs && point.timeMs < toMs) point.position, + positionAt(toMs), + ]; + factory ReplayUtility.fromJson(Map json) => ReplayUtility( id: json['id'] as int, classPath: json['classPath'] as String, @@ -448,6 +466,13 @@ class ReplayVector { final double y; final double z; + /// The point [t] of the way from here to [other]. + ReplayVector lerp(ReplayVector other, double t) => ReplayVector( + x + (other.x - x) * t, + y + (other.y - y) * t, + z + (other.z - z) * t, + ); + factory ReplayVector.fromJson(List json) => ReplayVector( _finite(json[0] as num), _finite(json[1] as num), diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index d457d2e8a..aa365862b 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:ui'; +import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; @@ -8,11 +9,13 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/replay/replay_ability_catalog.dart'; +import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_agents.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_weapons.dart'; import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/view_cone/vision_occluders.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; /// One moment of a replay as Icarus draws it: the same placed widgets a page @@ -25,6 +28,10 @@ class ReplayFrame { required this.agents, required this.abilities, required this.utilities, + this.occluders = const [], + this.cones = const {}, + this.dimmed = const {}, + this.effects = const [], }); final int timeMs; @@ -37,9 +44,93 @@ class ReplayFrame { final List abilities; final List utilities; + /// Smokes and smoke walls up at this moment, which cut the view cones. + final List occluders; + + /// Each living agent's latest cone cut, by agent id, when cones are cut + /// on a worker (see [ReplayConeCuts]). Empty when each cone cuts its own. + final Map cones; + + /// Abilities drawn faintly, by id: ranges and areas players stand in, so + /// the agents and cones under them stay readable. Smokes stay solid. + final Set dimmed; + + /// What the viewer animates around the placed widgets: utility arriving, + /// leaving, and in flight. Never part of a capture. + final List effects; + List get widgets => [...utilities, ...abilities, ...agents]; } +/// A player's view cone as drawn: where it is aimed now, and its latest cut +/// (see [ReplayConeCuts]), which may lag the aim by a frame or two and is +/// carried along to it. No cut yet draws no cone. +typedef ReplayConeSource = ({ReplayConeAim aim, ReplayConeCut? cut}); + +/// Something the viewer animates for a moment. Positions are canonical +/// world units, like placed widgets'. +sealed class ReplayEffect { + const ReplayEffect({required this.isAlly}); + + final bool isAlly; +} + +/// A utility activating: a ring spreading from it as it arrives. +class ReplayActivation extends ReplayEffect { + const ReplayActivation({ + required this.centre, + required this.radius, + required this.progress, + required super.isAlly, + }); + + final Offset centre; + + /// How far the ring spreads, world units. + final double radius; + + /// 0 as it activates, 1 when the ring has faded. + final double progress; +} + +/// A utility ending (destroyed, expired, or popped): its ability fading out +/// where it last stood, with a ring bursting from it. +class ReplayExit extends ReplayEffect { + const ReplayExit({ + required this.ability, + required this.centre, + required this.radius, + required this.progress, + required this.dimmed, + required super.isAlly, + }); + + final PlacedAbility ability; + final Offset centre; + final double radius; + + /// 0 as it ends, 1 when it is gone. + final double progress; + + /// Whether the ability was drawn faintly while it was up. + final bool dimmed; +} + +/// A thrown utility in flight: its ability's icon where it is now, and the +/// path it took over the last moment. +class ReplayFlight extends ReplayEffect { + const ReplayFlight({ + required this.iconPath, + required this.trail, + required super.isAlly, + }); + + final String iconPath; + + /// Oldest first; the last point is where it is now. + final List trail; +} + /// How a player stands at one moment. class ReplayPlayerState { const ReplayPlayerState({ @@ -73,7 +164,7 @@ class ReplayFrameBuilder { SvgHeightVisibility? heightModel; /// Cone length the editor allows at most; walls cut it down. - static const _coneLength = ViewConeUtility.maxLength; + static const coneLength = ViewConeUtility.maxLength; late final Map> _deathsBySubject = () { final deaths = >{}; @@ -83,6 +174,11 @@ class ReplayFrameBuilder { return deaths; }(); + /// Each utility's catalog entry, looked up once rather than every frame. + late final List _entries = [ + for (final utility in document.utility) replayAbilityFor(utility.classPath), + ]; + /// The owner of each utility actor when the replay does not name one but /// only one player could have cast it. late final Map _soleCasterByAgent = () { @@ -184,28 +280,33 @@ class ReplayFrameBuilder { document.playerBySubject(utility.owner) ?? _soleCasterByAgent[entry.agent]; - /// The moment at [timeMs]. [poseTimeOf] may give a player's pose an - /// earlier time; see [ReplayPlayerState] and `ReplayPlayback.frame`. + /// How long a utility's arrival ring and its exit last. + static const activationMs = 450; + static const exitMs = 500; + + /// How much of a thrown utility's path trails behind it. + static const trailMs = 350; + + /// The moment at [timeMs]. With [cuts], each living player's cone is + /// asked for where they are now and drawn from its latest cut; see + /// [ReplayFrame.cones]. ReplayFrame frameAt( int timeMs, { required ReplayTeam perspective, - int Function(ReplayPlayer player)? poseTimeOf, + ReplayConeCuts? cuts, }) { final round = document.roundAt(timeMs); final isAttack = (round?.attackingTeam ?? ReplayTeam.red) == perspective; final agentAnchor = CoordinateSystem.virtualToWorld(storedAgentAnchor); final agents = []; + final cones = {}; for (final player in document.players) { final type = replayAgentType(player.agentId); final team = player.team; // Without an agent or a team there is nothing honest to draw. if (type == null || team == null) continue; - final state = playerState( - player, - timeMs, - poseTimeMs: poseTimeOf?.call(player), - ); + final state = playerState(player, timeMs); final pose = state.pose; if (pose == null) continue; final standing = projection.toWorld(pose.position.x, pose.position.y); @@ -215,6 +316,23 @@ class ReplayFrameBuilder { final position = standing - agentAnchor; final isAlly = team == perspective; final id = 'replay-player-${player.subject}'; + final visionElevation = _visionElevation(pose); + final rotation = projection.rotationForYaw(pose.yaw); + if (state.alive && cuts != null) { + final aim = ReplayConeAim( + origin: standing, + rotation: rotation, + isAttack: isAttack, + elevationCm: visionElevation, + ); + cuts.want(player.subject, aim); + final cut = cuts.latest(player.subject); + cones[id] = ( + aim: aim, + // A cut from the other side's map is no cut at all. + cut: cut != null && cut.aim.isAttack == isAttack ? cut : null, + ); + } // What they carried out of the buy phase. final weapon = replayWeaponType(round?.economyFor(player.subject)?.weapon); @@ -225,9 +343,9 @@ class ReplayFrameBuilder { type: type, position: position, presetType: UtilityType.viewCone180, - rotation: projection.rotationForYaw(pose.yaw), - length: _coneLength, - visionElevation: _visionElevation(pose), + rotation: rotation, + length: coneLength, + visionElevation: visionElevation, isAlly: isAlly, weapon: weapon, ) @@ -243,25 +361,80 @@ class ReplayFrameBuilder { } final abilities = []; - for (final utility in document.utility) { + final occluders = []; + final dimmed = {}; + final effects = []; + final mapScale = Maps.mapScale[projection.map] ?? 1.0; + for (var i = 0; i < document.utility.length; i++) { + final utility = document.utility[i]; if (utility.spawnMs > timeMs) break; - if (!utility.isActiveAt(timeMs)) continue; - final entry = replayAbilityFor(utility.classPath); + final endMs = utility.endMs; + final active = utility.isActiveAt(timeMs); + // An ending utility lingers a moment to fade out. + final leaving = !active && endMs != null && timeMs - endMs < exitMs; + if (!active && !leaving) continue; + final entry = _entries[i]; if (entry == null) continue; final owner = ownerOf(utility, entry); // An unknown owner among duplicate agents would be a guess at the // team colour; leave it out instead. final ownerTeam = owner?.team; if (ownerTeam == null) continue; - abilities.add( - entry.place( - utility: utility, - projection: projection, - timeMs: timeMs, - id: 'replay-utility-${utility.id}', - isAlly: ownerTeam == perspective, - ), + final isAlly = ownerTeam == perspective; + + if (entry.kind == ReplayUtilityKind.flight) { + if (active) { + effects.add(ReplayFlight( + iconPath: entry.ability.iconPath, + trail: [ + for (final point in utility.trail(timeMs - trailMs, timeMs)) + projection.toWorld(point.x, point.y), + ], + isAlly: isAlly, + )); + } + continue; + } + + // Where it last stood, for one that has just ended. + final shownAt = active ? timeMs : endMs! - 1; + final ability = entry.place( + utility: utility, + projection: projection, + timeMs: shownAt, + id: 'replay-utility-${utility.id}', + isAlly: isAlly, ); + final at = utility.positionAt(shownAt); + final centre = projection.toWorld(at.x, at.y); + final radius = _radiusOf(entry, mapScale); + final faint = + (entry.kind == ReplayUtilityKind.area && entry.blocks == null) || + entry.kind == ReplayUtilityKind.rectangle; + if (!active) { + effects.add(ReplayExit( + ability: ability, + centre: centre, + radius: radius, + progress: (timeMs - endMs!) / exitMs, + dimmed: faint, + isAlly: isAlly, + )); + continue; + } + abilities.add(ability); + if (faint) dimmed.add(ability.id); + final occluder = _occluderOf(entry, utility, timeMs, centre, radius); + if (occluder != null) occluders.add(occluder); + final age = timeMs - utility.spawnMs; + if (age < activationMs) { + effects.add(ReplayActivation( + centre: centre, + radius: radius, + progress: age / activationMs, + isAlly: isAlly, + )); + } } final utilities = []; @@ -294,6 +467,45 @@ class ReplayFrameBuilder { agents: agents, abilities: abilities, utilities: utilities, + occluders: occluders, + cones: cones, + dimmed: dimmed, + effects: effects, ); } + + /// How far an ability reaches from its centre, world units: a circle's + /// radius as drawn, otherwise about an icon's width. + static double _radiusOf(ReplayAbilityEntry entry, double mapScale) { + final shape = entry.ability.abilityData; + final virtual = shape is CircleAbility ? shape.size * mapScale / 2 : 18.0; + return CoordinateSystem.virtualLengthInWorld(virtual); + } + + /// What [utility] hides from sight now, when it is a smoke or a wall of + /// smoke or fire. + VisionOccluder? _occluderOf( + ReplayAbilityEntry entry, + ReplayUtility utility, + int timeMs, + Offset centre, + double radius, + ) { + switch (entry.blocks) { + case null: + return null; + case ReplayVisionBlock.sphere: + return CircleOccluder(centre, radius); + case ReplayVisionBlock.line: + // A wall raised point by point stands along its points; a guided + // one (High Tide) along where its head has been. + final points = utility.points.length >= 2 + ? utility.points + : utility.trail(utility.spawnMs, timeMs); + if (points.length < 2) return null; + return LineOccluder([ + for (final point in points) projection.toWorld(point.x, point.y), + ]); + } + } } diff --git a/lib/replay/replay_playback.dart b/lib/replay/replay_playback.dart index b1625ff6a..6407d00a0 100644 --- a/lib/replay/replay_playback.dart +++ b/lib/replay/replay_playback.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_map_projection.dart'; @@ -41,11 +42,9 @@ class ReplayPlayback extends ChangeNotifier { int get durationMs => document.match.durationMs; ReplayRound? get round => document.roundAt(_timeMs); - /// The moment on screen. While playing, each new frame moves only the - /// [_movesPerFrame] players whose shown pose is oldest, plus any older - /// than [_maxPoseAgeMs]: a moved player's view cone is cut against the - /// walls again, and all ten in one frame is more than a 165 Hz frame - /// holds. Paused, every player is exact; so is every capture. + /// The moment on screen, every player exactly where they are. With + /// [coneCuts], view cones are cut on a worker and drawn from their latest + /// cut, so the UI thread only draws; without, each cone cuts its own. ReplayFrame get frame { final cached = _frame; if (cached != null && @@ -54,45 +53,29 @@ class ReplayPlayback extends ChangeNotifier { return cached; } _framePerspective = _perspective; - if (_playing) { - _staggerPoses(); - } else { - _poseTimes.clear(); - } return _frame = frames.frameAt( _timeMs, perspective: _perspective, - poseTimeOf: (player) => _poseTimes[player.subject] ?? _timeMs, + cuts: _coneCuts, ); } ReplayFrame? _frame; ReplayTeam? _framePerspective; - static const _movesPerFrame = 3; - static const _maxPoseAgeMs = 66; - - /// When each player's shown pose was taken, while playing. - final _poseTimes = {}; + /// Where view cones are cut, once the worker is up. + ReplayConeCuts? get coneCuts => _coneCuts; + ReplayConeCuts? _coneCuts; + set coneCuts(ReplayConeCuts? cuts) { + _coneCuts = cuts; + _frame = null; + notifyListeners(); + } - void _staggerPoses() { - final players = document.players; - if (_poseTimes.length != players.length) { - for (final player in players) { - _poseTimes[player.subject] = _timeMs; - } - return; - } - final oldestFirst = [...players]..sort( - (a, b) => _poseTimes[a.subject]!.compareTo(_poseTimes[b.subject]!), - ); - for (var i = 0; i < oldestFirst.length; i++) { - final subject = oldestFirst[i].subject; - final age = _timeMs - _poseTimes[subject]!; - if (i < _movesPerFrame || age > _maxPoseAgeMs || age < 0) { - _poseTimes[subject] = _timeMs; - } - } + /// A cut arrived: show it, if the moment on screen is waiting for it. + void coneCutArrived() { + _frame = null; + if (!_playing) notifyListeners(); } /// The map's height model, for standing each cone on the right level; @@ -113,8 +96,6 @@ class ReplayPlayback extends ChangeNotifier { void pause() { if (!_playing) return; _playing = false; - // Show every player exactly where they are. - _frame = null; notifyListeners(); } @@ -135,7 +116,6 @@ class ReplayPlayback extends ChangeNotifier { void seek(int timeMs) { final clamped = timeMs.clamp(0, durationMs); _carryMs = 0; - _poseTimes.clear(); if (clamped == _timeMs) return; _timeMs = clamped; notifyListeners(); diff --git a/lib/replay_view.dart b/lib/replay_view.dart index 833abdc1d..98a42bf39 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -12,7 +12,11 @@ import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/replay/replay_cone_cuts.dart'; +import 'package:icarus/replay/replay_cone_worker.dart'; import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_files.dart'; import 'package:icarus/replay/replay_loader.dart'; @@ -60,6 +64,11 @@ class _ReplayViewState extends ConsumerState ReplayPlayback? _playback; ProviderSubscription>? _heightRuntime; SvgHeightVisibility? _heightModel; + SvgHeightRuntime? _heightRuntimeValue; + + /// Cuts view cones off the UI thread once the map's models are in. + ReplayConeCuts? _coneCuts; + var _startingConeCuts = false; ReplayDecodeException? _error; Duration _lastTick = Duration.zero; @@ -109,6 +118,7 @@ class _ReplayViewState extends ConsumerState )..addListener(_syncMapSide); if (_heightModel != null) playback.heightModel = _heightModel; setState(() => _playback = playback); + unawaited(_startConeCuts()); claimEditorState(); _ticker.start(); } on ReplayDecodeException catch (error) { @@ -129,7 +139,49 @@ class _ReplayViewState extends ConsumerState final model = runtime?.model(true); if (model == null || identical(model, _heightModel)) return; _heightModel = model; + _heightRuntimeValue = runtime; _playback?.heightModel = model; + unawaited(_startConeCuts()); + } + + /// Starts cutting view cones on a worker once both the replay and the + /// map's height models are in. Until then each cone cuts its own. + Future _startConeCuts() async { + final playback = _playback; + final runtime = _heightRuntimeValue; + if (playback == null || + runtime == null || + _coneCuts != null || + _startingConeCuts) { + return; + } + final registration = svgHeightRegistrationFor(runtime.map); + if (registration == null) return; + _startingConeCuts = true; + try { + final dependencies = ref.read(svgHeightRuntimeDependenciesProvider); + final worker = await ReplayConeWorker.start( + attackModel: await dependencies.loadModel(registration, true), + defenseModel: await dependencies.loadModel(registration, false), + ); + if (!mounted) { + worker.dispose(); + return; + } + _coneCuts = playback.coneCuts = ReplayConeCuts( + worker: worker, + map: runtime.map, + attackModel: runtime.model(true), + defenseModel: runtime.model(false), + coneLength: ReplayFrameBuilder.coneLength, + apertureDegrees: UtilityData.getViewConeAngle(UtilityType.viewCone180), + onCut: playback.coneCutArrived, + ); + } catch (_) { + // Without the worker each cone cuts its own, as in the editor. + } finally { + _startingConeCuts = false; + } } void _fail(ReplayDecodeException error) { @@ -195,6 +247,7 @@ class _ReplayViewState extends ConsumerState _heightRuntime?.close(); _loader.cancel(); _ticker.dispose(); + _coneCuts?.dispose(); _playback ?..removeListener(_syncMapSide) ..dispose(); diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 9af435da9..8a4ec6826 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -599,18 +599,42 @@ class SvgHeightVisibility { String? supportId, double? absoluteEyeElevationMeters, int arcSteps = 96, - }) { - final timer = Stopwatch()..start(); - final base = _horizontalCone( + }) => + withSightlineFloors( + horizontalCone( + origin: origin, + directionRadians: directionRadians, + range: range, + apertureRadians: apertureRadians, + cameraHeightMeters: cameraHeightMeters, + supportHeightAboveFloorMeters: supportHeightAboveFloorMeters, + supportId: supportId, + absoluteEyeElevationMeters: absoluteEyeElevationMeters, + arcSteps: arcSteps), origin: origin, directionRadians: directionRadians, range: range, apertureRadians: apertureRadians, cameraHeightMeters: cameraHeightMeters, - supportHeightAboveFloorMeters: supportHeightAboveFloorMeters, - supportId: supportId, - absoluteEyeElevationMeters: absoluteEyeElevationMeters, - arcSteps: arcSteps); + arcSteps: arcSteps, + ); + + /// Whether [withSightlineFloors] can change a cone at all on this map. + bool get hasSightlineFloors => sightlineFloors.isNotEmpty; + + /// [base], a [horizontalCone], with the reviewed destination floors it + /// overlooks cast down to a standing target (see `docs/vision-model.md`). + /// Uses `dart:ui` path operations, so it runs on the root isolate only. + SvgVisibilityCone withSightlineFloors( + SvgVisibilityCone base, { + required Offset origin, + required double directionRadians, + required double range, + required double apertureRadians, + double? cameraHeightMeters, + int arcSteps = 96, + }) { + final timer = Stopwatch()..start(); if (sightlineFloors.isEmpty || base.polygon.length < 3) return base; final eye = base.eyeElevationMeters!; Path? visibility; @@ -669,7 +693,9 @@ class SvgHeightVisibility { visibilityPath: visibility); } - SvgVisibilityCone _horizontalCone({ + /// The cone cut by a horizontal sightline at eye height alone. Uses no + /// `dart:ui` paths, so a worker isolate can run it. + SvgVisibilityCone horizontalCone({ required Offset origin, required double directionRadians, required double range, diff --git a/lib/view_cone/vision_occluders.dart b/lib/view_cone/vision_occluders.dart new file mode 100644 index 000000000..1f43b7325 --- /dev/null +++ b/lib/view_cone/vision_occluders.dart @@ -0,0 +1,166 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +/// Something that blocks sight for a moment, on top of the map's walls: a +/// smoke, or a wall of smoke or fire. Positions are canonical world units, +/// as placed widgets store them. It blocks at every eye height. +sealed class VisionOccluder { + const VisionOccluder(); + + /// Whether any of it lies within [reach] of [origin]. + bool reaches(Offset origin, double reach); +} + +/// A smoke: a sphere seen from above. +@immutable +class CircleOccluder extends VisionOccluder { + const CircleOccluder(this.centre, this.radius); + + final Offset centre; + final double radius; + + CircleOccluder map(Offset Function(Offset) point, double scale) => + CircleOccluder(point(centre), radius * scale); + + @override + bool reaches(Offset origin, double reach) => + (centre - origin).distance < reach + radius; + + /// What this smoke hides from [origin], within [reach] of it: the smoke + /// and the shadow behind it, in the space [origin] is in. Null when + /// [origin] is inside it, which hides everything. + List? shadow(Offset origin, double reach) { + final toCentre = centre - origin; + final distance = toCentre.distance; + // On its rim counts as inside: the shadow would be a half-plane. + if (distance <= radius * 1.001) return null; + final heading = math.atan2(toCentre.dy, toCentre.dx); + final spread = math.asin(radius / distance); + final tangent = math.sqrt(distance * distance - radius * radius); + // Far enough that the shadow's far edge lies beyond the cone's reach, + // and no farther: path operations fail on runaway coordinates. + final far = math.min( + (reach + distance) * distance / tangent * 1.5, (reach + distance) * 50); + Offset along(double angle, double length) => + origin + Offset(math.cos(angle), math.sin(angle)) * length; + // The near side of the circle, tangent to tangent, then out along the + // tangents. The far side of the circle lies inside the shadow. + final points = []; + const steps = 16; + final start = heading + spread + math.pi / 2; + final sweep = math.pi - 2 * spread; + for (var i = 0; i <= steps; i++) { + final angle = start + sweep * i / steps; + points.add(centre + Offset(math.cos(angle), math.sin(angle)) * radius); + } + points + ..add(along(heading - spread, far)) + ..add(along(heading + spread, far)); + return points; + } + + @override + bool operator ==(Object other) => + other is CircleOccluder && + other.centre == centre && + other.radius == radius; + + @override + int get hashCode => Object.hash(centre, radius); +} + +/// A wall of smoke or fire, as a line through [points]. +@immutable +class LineOccluder extends VisionOccluder { + const LineOccluder(this.points); + + final List points; + + LineOccluder map(Offset Function(Offset) point) => + LineOccluder([for (final p in points) point(p)]); + + @override + bool reaches(Offset origin, double reach) { + for (var i = 0; i + 1 < points.length; i++) { + final a = points[i], b = points[i + 1]; + final edge = b - a; + final length = edge.distanceSquared; + final t = length == 0 + ? 0.0 + : (((origin - a).dx * edge.dx + (origin - a).dy * edge.dy) / length) + .clamp(0.0, 1.0); + if ((a + edge * t - origin).distance < reach) return true; + } + return false; + } + + /// What each segment hides from [origin], within [reach] of it. + List> shadows(Offset origin, double reach) { + final out = >[]; + for (var i = 0; i + 1 < points.length; i++) { + final a = points[i], b = points[i + 1]; + final toA = a - origin, toB = b - origin; + // Seen edge on, a segment hides nothing. + if ((toA.dx * toB.dy - toA.dy * toB.dx).abs() < 1e-9) continue; + final far = reach + math.max(toA.distance, toB.distance); + out.add([ + a, + b, + origin + toB / toB.distance * (far * 2), + origin + toA / toA.distance * (far * 2), + ]); + } + return out; + } + + @override + bool operator ==(Object other) => + other is LineOccluder && listEquals(other.points, points); + + @override + int get hashCode => Object.hashAll(points); +} + +/// Whether [origin] stands inside a smoke, where nothing beyond it shows. +bool standsInsideSmoke(Offset origin, List occluders) => + occluders.any((occluder) => + occluder is CircleOccluder && occluder.shadow(origin, 0) == null); + +/// What [occluders] hide from [origin], within [reach] of it: one path, in +/// the space [origin] is in, for the cone painter to erase. Null when they +/// hide nothing. Erasing at paint time keeps path operations, which cost a +/// millisecond or more against a cone's wall cut, off the UI thread. +Path? occluderShadows( + Offset origin, + double reach, + List occluders, +) { + final shadows = Path()..fillType = PathFillType.nonZero; + var any = false; + for (final occluder in occluders) { + final polygons = switch (occluder) { + CircleOccluder() => [occluder.shadow(origin, reach)], + LineOccluder() => occluder.shadows(origin, reach), + }; + for (final polygon in polygons) { + if (polygon == null) continue; + // One winding for all, so overlapping shadows add up rather than + // cancel under the non-zero rule. + shadows.addPolygon( + _signedArea(polygon) < 0 ? polygon.reversed.toList() : polygon, true); + any = true; + } + } + return any ? shadows : null; +} + +double _signedArea(List polygon) { + var area = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i], b = polygon[(i + 1) % polygon.length]; + area += a.dx * b.dy - b.dx * a.dy; + } + return area / 2; +} diff --git a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart index 02430248a..7c84f0a7b 100644 --- a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart +++ b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart @@ -61,6 +61,20 @@ class SplitSvgMapTransform { _transform.sideWorldFromSource(point, isAttack: isAttack); } +/// Marks view cones below as drawn by someone else. The replay viewer +/// paints every cone in one layer from cuts made off the UI thread; the +/// agents it places would otherwise each cut and paint their own again. +class ViewConesDrawnElsewhere extends InheritedWidget { + const ViewConesDrawnElsewhere({super.key, required super.child}); + + static bool of(BuildContext context) => + context.getInheritedWidgetOfExactType() != + null; + + @override + bool updateShouldNotify(ViewConesDrawnElsewhere oldWidget) => false; +} + /// A mounted child owns one cache key. Preview cones with no persisted ID still /// remain independent, and unmounting removes only that observer's cached cone. class SvgHeightViewCone extends StatefulWidget { @@ -112,6 +126,7 @@ class _SvgHeightViewConeState extends State { @override Widget build(BuildContext context) { + if (ViewConesDrawnElsewhere.of(context)) return const SizedBox.expand(); final coordinates = CoordinateSystem.instance; final model = widget.runtime.model(widget.isAttack); final mapTransform = SvgHeightMapTransform.forMap(widget.runtime.map); @@ -281,6 +296,10 @@ class SvgHeightViewConePainter extends CustomPainter { canvas.restore(); } + /// [rings] as a path, the way cones are clipped by them. + static Path ringsPath(List> rings, {required bool evenOdd}) => + _path(rings, evenOdd: evenOdd); + static Path _path(List> rings, {required bool evenOdd}) { final path = Path() ..fillType = evenOdd ? PathFillType.evenOdd : PathFillType.nonZero; diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 674e92e18..8be1c8e00 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -1,14 +1,20 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/widgets/canonical_map_artwork.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/map_svg_color_mapper.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; import 'package:icarus/widgets/page_transition_overlay.dart'; +import 'package:icarus/widgets/replay/replay_cones.dart'; +import 'package:icarus/widgets/replay/replay_effects.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// The map as the editor draws it, with the replay's current moment on top. @@ -30,6 +36,26 @@ class ReplayCanvas extends ConsumerStatefulWidget { ConsumerState createState() => _ReplayCanvasState(); } +/// How strongly ranges and areas players stand in are drawn, so the agents +/// and cones beneath stay readable. +const _rangeOpacity = 0.3; + +/// [view] drawn at [opacity]. A placed widget comes back positioned on the +/// canvas, so the fade goes inside its position. +Widget _faded(Widget view, double opacity) { + if (view is! Positioned) return Opacity(opacity: opacity, child: view); + return Positioned( + key: view.key, + left: view.left, + top: view.top, + right: view.right, + bottom: view.bottom, + width: view.width, + height: view.height, + child: Opacity(opacity: opacity, child: view.child), + ); +} + class _ReplayCanvasState extends ConsumerState { final _controller = TransformationController(); Size? _lastViewport; @@ -101,6 +127,22 @@ class _ReplayCanvasState extends ConsumerState { final mapName = Maps.mapNames[widget.map]; final widgets = frame.widgets ..sort(PageLayering.comparePlacedWidgets); + final mapScale = Maps.mapScale[widget.map]!; + final abilitySize = + preferences.defaultAbilitySizeForNewStrategies; + final agentSize = + preferences.defaultAgentSizeForNewStrategies; + Widget view(PlacedWidget placed) => staticPlacedWidgetView( + widget: placed, + mapScale: mapScale, + abilitySize: abilitySize, + agentSize: agentSize, + isAttack: isAttack, + ); + final exits = frame.effects.whereType(); + // Cones cut on the worker are drawn in one layer. + final cuts = widget.playback.coneCuts; + final flights = frame.effects.whereType(); return Stack( clipBehavior: Clip.none, children: [ @@ -128,16 +170,69 @@ class _ReplayCanvasState extends ConsumerState { ), ), ), - for (final placed in widgets) - staticPlacedWidgetView( - widget: placed, - mapScale: Maps.mapScale[widget.map]!, - abilitySize: - preferences.defaultAbilitySizeForNewStrategies, - agentSize: - preferences.defaultAgentSizeForNewStrategies, + // Utility moves far less than players do: its own + // layer, rebuilt only when it changes, so its + // picture (faded ranges and all) is drawn from cache. + Positioned.fill( + child: _UtilityLayer( + // How it is drawn, beyond the pieces themselves. + look: (isAttack, abilitySize, mapScale), + placed: [ + for (final placed in widgets) + if (placed is! PlacedAgentNode) placed, + ], + dimmed: frame.dimmed, + view: view, + ), + ), + for (final exit in exits) + _faded( + view(exit.ability), + (1 - exit.progress) * + (exit.dimmed ? _rangeOpacity : 1), + ), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: ReplayEffectsPainter( + effects: frame.effects, + isAttack: isAttack, + iconSize: + CoordinateSystem.instance.scale(abilitySize), + ), + ), + ), + ), + for (final flight in flights) + ReplayFlightIcon( + flight: flight, isAttack: isAttack, + abilitySize: abilitySize, + ), + if (cuts != null) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: ReplayConesPainter( + cones: frame.cones.values, + occluders: frame.occluders, + model: isAttack + ? cuts.attackModel + : cuts.defenseModel, + map: widget.map, + isAttack: isAttack, + ), + ), + ), ), + for (final placed in widgets) + if (placed is PlacedAgentNode) + cuts == null + ? view(placed) + : ViewConesDrawnElsewhere( + key: ValueKey(placed.id), + child: view(placed), + ), ], ); }, @@ -149,3 +244,63 @@ class _ReplayCanvasState extends ConsumerState { ); } } + +/// The utility on the map, rebuilt only when a piece of it appears, moves, +/// turns or goes, and kept on its own layer so an unchanged picture is +/// reused rather than redrawn every frame. +class _UtilityLayer extends StatefulWidget { + const _UtilityLayer({ + required this.look, + required this.placed, + required this.dimmed, + required this.view, + }); + + final Object look; + final List placed; + final Set dimmed; + final Widget Function(PlacedWidget placed) view; + + @override + State<_UtilityLayer> createState() => _UtilityLayerState(); +} + +class _UtilityLayerState extends State<_UtilityLayer> { + List? _shownKey; + Widget? _shown; + + /// What the layer draws, as values: each piece's identity and placement. + List get _key => [ + widget.look, + for (final placed in widget.placed) ...[ + placed.id, + placed.position, + if (placed is PlacedAbility) ...[ + placed.rotation, + placed.length, + ...placed.armLengthsMeters, + ], + widget.dimmed.contains(placed.id), + ], + ]; + + @override + Widget build(BuildContext context) { + final key = _key; + if (_shown == null || !listEquals(key, _shownKey)) { + _shownKey = key; + _shown = RepaintBoundary( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final placed in widget.placed) + widget.dimmed.contains(placed.id) + ? _faded(widget.view(placed), _rangeOpacity) + : widget.view(placed), + ], + ), + ); + } + return _shown!; + } +} diff --git a/lib/widgets/replay/replay_cones.dart b/lib/widgets/replay/replay_cones.dart new file mode 100644 index 000000000..fa6434c16 --- /dev/null +++ b/lib/widgets/replay/replay_cones.dart @@ -0,0 +1,167 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/view_cone/vision_occluders.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; + +/// Every view cone of a replay moment in one layer, drawn as the editor +/// draws a placed cone (the same cut, gradient and clip to the painted +/// floor) but clipped to the floor once for all of them, from cuts made off +/// the UI thread. A cut lagging its agent is carried along and turned with +/// it. Smokes and smoke walls are erased from each cone they hide part of. +class ReplayConesPainter extends CustomPainter { + ReplayConesPainter({ + required this.cones, + required this.occluders, + required this.model, + required this.map, + required this.isAttack, + }); + + final Iterable cones; + final List occluders; + + /// The side's height model the cuts were made in. + final SvgHeightVisibility model; + final MapValue map; + final bool isAttack; + + static final _conePaths = Expando(); + static final _floors = Expando<(Size, Float64List, Path)>(); + + /// The editor's cone fill, see [SvgHeightViewConePainter]. + static const _coneGrey = Color.fromARGB(255, 147, 147, 147); + + @override + void paint(Canvas canvas, Size size) { + final coordinates = CoordinateSystem.instance; + final transform = SvgHeightMapTransform.forMap(map); + // Height-model space to the canvas: an even scale and an offset. + final scale = coordinates.worldHeightToScreen(transform.scale); + final toScreen = (Offset source) => coordinates.coordinateToScreen( + transform.sideWorldFromSource(source, isAttack: isAttack)); + final shift = toScreen(Offset.zero); + final sourceToScreen = Float64List(16) + ..[0] = scale + ..[5] = scale + ..[10] = 1 + ..[12] = shift.dx + ..[13] = shift.dy + ..[15] = 1; + + canvas.save(); + // Cone pixels never land off the painted floor, nor in its holes. The + // fill is faint at any floor edge, which wall ink mostly covers, so a + // hard edge reads the same and spares an antialiased mask every frame. + canvas.clipPath(_floor(size, sourceToScreen), doAntiAlias: false); + for (final (:aim, :cut) in cones) { + final cone = cut?.cone; + final origin = cut?.origin; + if (cut == null || cone == null || origin == null) continue; + if (cone.polygon.length < 3) continue; + // Carry the cut to where its agent is now, and turn it with them. + final moved = toScreen(_source(aim.origin, transform)) - + toScreen(_source(cut.aim.origin, transform)); + final turn = + coordinates.rotationForSide(aim.rotation, isAttack: isAttack) - + coordinates.rotationForSide(cut.aim.rotation, isAttack: isAttack); + final apex = toScreen(origin) + moved; + + canvas.save(); + canvas.translate(apex.dx, apex.dy); + canvas.rotate(turn); + canvas.translate(-apex.dx + moved.dx, -apex.dy + moved.dy); + canvas.transform(sourceToScreen); + + final reach = _range(transform); + final shadows = + occluders.isEmpty ? null : _shadows(origin, reach, transform); + if (shadows == _blind) { + canvas.restore(); + continue; + } + final bounds = Rect.fromCircle(center: origin, radius: reach); + if (shadows != null) canvas.saveLayer(bounds, Paint()); + // The cut lies within reach, so filling it with the editor's gradient + // is the editor's clipped circle, without a clip mask per cone. + canvas.drawPath( + _conePaths[cone] ??= _conePath(cone), + Paint() + ..shader = RadialGradient( + colors: [_coneGrey.withValues(alpha: .5), Colors.transparent], + ).createShader(bounds), + ); + if (shadows != null) { + canvas.drawPath(shadows, Paint()..blendMode = BlendMode.clear); + canvas.restore(); + } + canvas.restore(); + } + canvas.restore(); + } + + /// A view cone's reach in height-model space. + static double _range(SvgHeightMapTransform transform) => + CoordinateSystem.virtualLengthInWorld(ReplayFrameBuilder.coneLength) / + transform.scale; + + Offset _source(Offset canonical, SvgHeightMapTransform transform) => + transform.sourceFromSideWorld( + CoordinateSystem.instance.positionForSide( + canonicalPosition: canonical, + reflectionOffset: Offset.zero, + isAttack: isAttack, + ), + isAttack: isAttack, + ); + + static final _blind = Path(); + + /// What the occluders within reach hide from [origin], in height-model + /// space; [_blind] when [origin] stands in a smoke. + Path? _shadows(Offset origin, double reach, SvgHeightMapTransform transform) { + final near = [ + for (final occluder in occluders) + switch (occluder) { + CircleOccluder() => + occluder.map((p) => _source(p, transform), 1 / transform.scale), + LineOccluder() => occluder.map((p) => _source(p, transform)), + }, + ].where((occluder) => occluder.reaches(origin, reach)).toList(); + if (near.isEmpty) return null; + if (standsInsideSmoke(origin, near)) return _blind; + return occluderShadows(origin, reach, near); + } + + static Path _conePath(SvgVisibilityCone cone) => + cone.visibilityPath ?? (Path()..addPolygon(cone.polygon, true)); + + /// The painted floor on the canvas, built once per canvas size and side. + Path _floor(Size size, Float64List sourceToScreen) { + final cached = _floors[model]; + if (cached != null && + cached.$1 == size && + cached.$2[0] == sourceToScreen[0] && + cached.$2[12] == sourceToScreen[12] && + cached.$2[13] == sourceToScreen[13]) { + return cached.$3; + } + Path? floor; + for (final receiver in model.receivers) { + final next = SvgHeightViewConePainter.ringsPath(receiver.rings, + evenOdd: receiver.evenOdd); + floor = + floor == null ? next : Path.combine(PathOperation.union, floor, next); + } + final screen = (floor ?? Path()).transform(sourceToScreen); + _floors[model] = (size, sourceToScreen, screen); + return screen; + } + + @override + bool shouldRepaint(ReplayConesPainter oldDelegate) => true; +} diff --git a/lib/widgets/replay/replay_dock.dart b/lib/widgets/replay/replay_dock.dart index 16478f4a4..71c14add3 100644 --- a/lib/widgets/replay/replay_dock.dart +++ b/lib/widgets/replay/replay_dock.dart @@ -3,6 +3,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/widgets/replay/replay_match_card.dart'; +import 'package:icarus/widgets/replay/replay_select.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Playback along the bottom: every round of the match, then the round being @@ -23,28 +24,38 @@ class ReplayDock extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.colorScheme.border), ), - child: ListenableBuilder( - listenable: playback, - builder: (context, _) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RoundStrip(playback: playback), - const SizedBox(height: 8), - Row( - children: [ - ShadIconButton.ghost( + // Only the clock and the scrubber change every frame; the rest + // rebuilds when what it shows does. + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ReplaySelect( + listenable: playback, + select: () => (playback.round?.index, playback.perspective), + builder: (context, _) => _RoundStrip(playback: playback), + ), + const SizedBox(height: 8), + Row( + children: [ + ReplaySelect( + listenable: playback, + select: () => playback.playing, + builder: (context, playing) => ShadIconButton.ghost( width: 32, height: 32, onPressed: playback.togglePlaying, icon: Icon( - playback.playing ? LucideIcons.pause : LucideIcons.play, + playing ? LucideIcons.pause : LucideIcons.play, size: 18, ), ), - const SizedBox(width: 4), - SizedBox( - width: 40, - child: Text( + ), + const SizedBox(width: 4), + SizedBox( + width: 40, + child: ListenableBuilder( + listenable: playback, + builder: (context, _) => Text( _roundClock(playback), style: const TextStyle( fontSize: 12, @@ -53,14 +64,23 @@ class ReplayDock extends StatelessWidget { ), ), ), - const SizedBox(width: 8), - Expanded(child: _RoundScrubber(playback: playback)), - const SizedBox(width: 12), - _SpeedButton(playback: playback), - ], - ), - ], - ), + ), + const SizedBox(width: 8), + Expanded( + child: ListenableBuilder( + listenable: playback, + builder: (context, _) => _RoundScrubber(playback: playback), + ), + ), + const SizedBox(width: 12), + ReplaySelect( + listenable: playback, + select: () => playback.speed, + builder: (context, _) => _SpeedButton(playback: playback), + ), + ], + ), + ], ), ); } diff --git a/lib/widgets/replay/replay_effects.dart b/lib/widgets/replay/replay_effects.dart new file mode 100644 index 000000000..c7be0792e --- /dev/null +++ b/lib/widgets/replay/replay_effects.dart @@ -0,0 +1,136 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/widgets/draggable_widgets/shared/framed_ability_icon_shell.dart'; + +/// Where a canonical world point is drawn on the canvas, on [isAttack]'s side. +Offset replayScreenPoint(Offset canonical, {required bool isAttack}) { + final coordinates = CoordinateSystem.instance; + return coordinates.coordinateToScreen(coordinates.positionForSide( + canonicalPosition: canonical, + reflectionOffset: Offset.zero, + isAttack: isAttack, + )); +} + +/// The rings and trails of a replay moment: utility arriving and leaving, +/// and the path a thrown utility just took. +class ReplayEffectsPainter extends CustomPainter { + ReplayEffectsPainter({ + required this.effects, + required this.isAttack, + required this.iconSize, + }); + + final List effects; + final bool isAttack; + + /// The ability icon's size on screen; rings never start inside it. + final double iconSize; + + static Color _ink(bool isAlly) => + isAlly ? Settings.allyOutlineColor : Settings.enemyOutlineColor; + + @override + void paint(Canvas canvas, Size size) { + final coordinates = CoordinateSystem.instance; + double reach(double worldRadius) => + math.max(coordinates.worldHeightToScreen(worldRadius), iconSize * 0.75); + for (final effect in effects) { + final ink = _ink(effect.isAlly); + switch (effect) { + case ReplayActivation(:final centre, :final radius, :final progress): + // A ring spreading out to the ability's reach as it takes hold. + final eased = Curves.easeOutCubic.transform(progress); + final full = reach(radius); + canvas.drawCircle( + replayScreenPoint(centre, isAttack: isAttack), + full * (0.3 + 0.8 * eased), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = coordinates.scale(1 + 2.5 * (1 - progress)) + ..color = ink.withValues(alpha: 0.9 * (1 - progress)), + ); + case ReplayExit(:final centre, :final radius, :final progress): + // A brief flash where it stood, and a ring bursting outward. + final eased = Curves.easeOutCubic.transform(progress); + final full = reach(radius); + final at = replayScreenPoint(centre, isAttack: isAttack); + if (progress < 0.5) { + canvas.drawCircle( + at, + full * 0.9, + Paint()..color = ink.withValues(alpha: 0.25 * (1 - progress * 2)), + ); + } + canvas.drawCircle( + at, + full * (0.9 + 0.6 * eased), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = coordinates.scale(2 * (1 - progress) + 0.5) + ..color = ink.withValues(alpha: 0.8 * (1 - progress)), + ); + case ReplayFlight(:final trail): + // The path just flown, fading toward its tail. + if (trail.length < 2) continue; + final points = [ + for (final point in trail) + replayScreenPoint(point, isAttack: isAttack), + ]; + for (var i = 1; i < points.length; i++) { + final age = 1 - i / (points.length - 1); + canvas.drawLine( + points[i - 1], + points[i], + Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = coordinates.scale(2.5) + ..color = ink.withValues(alpha: 0.85 * (1 - age * 0.8)), + ); + } + } + } + } + + @override + bool shouldRepaint(ReplayEffectsPainter oldDelegate) => + oldDelegate.effects != effects || oldDelegate.isAttack != isAttack; +} + +/// A thrown utility's icon where it is now, a little smaller than the +/// ability it will become. +class ReplayFlightIcon extends StatelessWidget { + const ReplayFlightIcon({ + super.key, + required this.flight, + required this.isAttack, + required this.abilitySize, + }); + + final ReplayFlight flight; + final bool isAttack; + final double abilitySize; + + static const _scale = 0.7; + + @override + Widget build(BuildContext context) { + final size = CoordinateSystem.instance.scale(abilitySize * _scale); + final head = replayScreenPoint(flight.trail.last, isAttack: isAttack); + return Positioned( + left: head.dx - size / 2, + top: head.dy - size / 2, + child: IgnorePointer( + child: FramedAbilityIconShell( + size: abilitySize * _scale, + isAlly: flight.isAlly, + child: Image.asset(flight.iconPath, fit: BoxFit.contain), + ), + ), + ); + } +} diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 64da5ccb6..0b262a222 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -9,6 +9,7 @@ import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/replay_view.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/strategy_view.dart'; +import 'package:icarus/widgets/replay/replay_select.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Where the match stands, and the one thing to do with it: capture the @@ -106,13 +107,18 @@ class _ReplayMatchCardState extends ConsumerState { borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.colorScheme.border), ), - child: ListenableBuilder( + // The round, the side and the score change a few times a round, not + // every frame. + child: ReplaySelect( listenable: widget.playback, - builder: (context, _) { + select: () => ( + widget.playback.round, + _score(widget.playback), + widget.playback.frame.isAttack, + ), + builder: (context, shown) { final playback = widget.playback; - final round = playback.round; - final score = _score(playback); - final isAttack = playback.frame.isAttack; + final (round, score, isAttack) = shown; final captured = _capture.strategy?.pages.length ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart index d206e0d3c..5d034adff 100644 --- a/lib/widgets/replay/replay_roster.dart +++ b/lib/widgets/replay/replay_roster.dart @@ -8,6 +8,7 @@ import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/replay/replay_weapons.dart'; import 'package:icarus/widgets/replay/replay_match_card.dart'; +import 'package:icarus/widgets/replay/replay_select.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Both teams as they stand right now, then the round's kills. @@ -28,8 +29,9 @@ class ReplayRoster extends StatelessWidget { borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.colorScheme.border), ), - child: ListenableBuilder( + child: ReplaySelect>( listenable: playback, + select: _shown, builder: (context, _) { final perspective = playback.perspective; return Column( @@ -56,6 +58,29 @@ class ReplayRoster extends StatelessWidget { } } +extension on ReplayRoster { + /// Everything the roster shows, coarsely: it rebuilds when this changes, + /// not on every frame. + List _shown() { + final now = playback.timeMs; + final round = playback.round; + return [ + playback.perspective, + round?.index, + for (final player in playback.document.players) + if (playback.frames.playerState(player, now) case final state) ...[ + state.alive, + state.health?.round(), + state.armor?.round(), + ], + if (round != null) + playback.document.kills + .where((kill) => kill.timeMs >= round.startMs && kill.timeMs <= now) + .length, + ]; + } +} + class _TeamBlock extends StatelessWidget { const _TeamBlock({ required this.playback, diff --git a/lib/widgets/replay/replay_select.dart b/lib/widgets/replay/replay_select.dart new file mode 100644 index 000000000..bcf50760c --- /dev/null +++ b/lib/widgets/replay/replay_select.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Rebuilds [builder] only when what [select] reads from [listenable] +/// changes. Playback notifies every frame; most of the panels around the +/// map show something that changes a few times a round, and rebuilding them +/// at 165 Hz is most of a frame's budget. +class ReplaySelect extends StatefulWidget { + const ReplaySelect({ + super.key, + required this.listenable, + required this.select, + required this.builder, + this.equals, + }); + + final Listenable listenable; + final T Function() select; + final Widget Function(BuildContext context, T value) builder; + + /// How two selections compare; `==` by default, element by element for + /// lists. + final bool Function(T a, T b)? equals; + + @override + State> createState() => _ReplaySelectState(); +} + +class _ReplaySelectState extends State> { + late T _value = widget.select(); + + bool _same(T a, T b) { + final equals = widget.equals; + if (equals != null) return equals(a, b); + if (a is List && b is List) return listEquals(a, b); + return a == b; + } + + @override + void initState() { + super.initState(); + widget.listenable.addListener(_changed); + } + + @override + void didUpdateWidget(ReplaySelect oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.listenable != widget.listenable) { + oldWidget.listenable.removeListener(_changed); + widget.listenable.addListener(_changed); + } + _value = widget.select(); + } + + @override + void dispose() { + widget.listenable.removeListener(_changed); + super.dispose(); + } + + void _changed() { + final next = widget.select(); + if (_same(_value, next)) return; + setState(() => _value = next); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} diff --git a/test/replay_ability_catalog_test.dart b/test/replay_ability_catalog_test.dart index f5e2ed62e..7ed1e32a0 100644 --- a/test/replay_ability_catalog_test.dart +++ b/test/replay_ability_catalog_test.dart @@ -644,11 +644,52 @@ const observedMapped = { (AgentType.omen, 3), '/Game/Characters/Wushu/S0/Ability_4/GameObject_Wushu_4_SmokeZone.GameObject_Wushu_4_SmokeZone_C': (AgentType.jett, 0), + '/Game/Characters/AggroBot/S0/Ability_4/Projectile_Aggrobot_C_ExplodeyPatch.Projectile_Aggrobot_C_ExplodeyPatch_C': + (AgentType.gekko, 0), + '/Game/Characters/BountyHunter/S0/Ability_Q/Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion.Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion_C': + (AgentType.fade, 1), + '/Game/Characters/Breach/S0/Ability_4/Projectile_Breach_4_FusionBlast.Projectile_Breach_4_FusionBlast_C': + (AgentType.breach, 0), + '/Game/Characters/Cable/S0/Ability_4/Projectile_NetToss.Projectile_NetToss_C': + (AgentType.deadlock, 0), + '/Game/Characters/Cable/S0/Ability_E/Projectile_CableJam_InAir.Projectile_CableJam_InAir_C': + (AgentType.deadlock, 2), + '/Game/Characters/Hunter/S0/Ability_4/Projectile_Hunter_4_ExplosiveBolt.Projectile_Hunter_4_ExplosiveBolt_C': + (AgentType.sova, 1), + '/Game/Characters/Hunter/S0/Ability_Q/Projectile_Hunter_Q_RevealBolt.Projectile_Hunter_Q_RevealBolt_C': + (AgentType.sova, 2), + '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Concuss.Projectile_Thumper_Concuss_C': + (AgentType.miks, 0), + '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Heal.Projectile_Thumper_Heal_C': + (AgentType.miks, 1), + '/Game/Characters/Nox/S0/Ability_4/Projectile_Nox_BarbedWire.Projectile_Nox_BarbedWire_C': + (AgentType.vyse, 1), + '/Game/Characters/Phoenix/S0/Ability_4/Production/Projectile_Phoenix_4_Molotov_Production.Projectile_Phoenix_4_Molotov_Production_C': + (AgentType.pheonix, 2), + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced.Projectile_Phoenix_E_FlareCurve_Synced_C': + (AgentType.pheonix, 1), + '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced_Right.Projectile_Phoenix_E_FlareCurve_Synced_Right_C': + (AgentType.pheonix, 1), + '/Game/Characters/Phoenix/S0/Ability_Q/Production/Projectile_Phoenix_Q_FlameWall_ThroughWall.Projectile_Phoenix_Q_FlameWall_ThroughWall_C': + (AgentType.pheonix, 0), + '/Game/Characters/Sarge/S0/Ability_Molotov/Projectile_Sarge_Q_Molotov_Production.Projectile_Sarge_Q_Molotov_Production_C': + (AgentType.brimstone, 1), + '/Game/Characters/Sarge/S0/Ability_SpeedStim/Projectile_Sarge_E_SpeedStim.Projectile_Sarge_E_SpeedStim_C': + (AgentType.brimstone, 0), + '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/Projectile_Smonk_DecayNade.Projectile_Smonk_DecayNade_C': + (AgentType.clove, 1), + '/Game/Characters/Thorne/S0/Ability_4/Projectile_Thorne_4_SlowFIeld_Production.Projectile_Thorne_4_SlowFIeld_Production_C': + (AgentType.sage, 1), + '/Game/Characters/Vampire/S0/Ability_4/Projectile_Vampire_4_NearsightAoE.Projectile_Vampire_4_NearsightAoE_C': + (AgentType.reyna, 0), + '/Game/Characters/Wraith/S0/Ability_4/Projectile_Wraith_4_Smoke.Projectile_Wraith_4_Smoke_C': + (AgentType.omen, 2), + '/Game/Characters/Wushu/S0/Ability_4/Projectile_Wushu_4_Smoke.Projectile_Wushu_4_Smoke_C': + (AgentType.jett, 0), }; const observedIgnored = [ '/Game/Characters/AggroBot/S0/Ability_4/Ability_Aggrobot_C_ExplodeyPatch.Ability_Aggrobot_C_ExplodeyPatch_C', - '/Game/Characters/AggroBot/S0/Ability_4/Projectile_Aggrobot_C_ExplodeyPatch.Projectile_Aggrobot_C_ExplodeyPatch_C', '/Game/Characters/AggroBot/S0/Ability_E/Ability_E_Aggrobot_DiscTurret.Ability_E_Aggrobot_DiscTurret_C', '/Game/Characters/AggroBot/S0/Ability_E/Projectile_Aggrobot_Zamboni_Rocket.Projectile_Aggrobot_Zamboni_Rocket_C', '/Game/Characters/AggroBot/S0/Ability_E/Projectile_E_Aggrobot_OrbSpawner.Projectile_E_Aggrobot_OrbSpawner_C', @@ -663,20 +704,16 @@ const observedIgnored = [ '/Game/Characters/BountyHunter/S0/Ability_4/Ability_BountyHunter_4_WolfHoundBendable.Ability_BountyHunter_4_WolfHoundBendable_C', '/Game/Characters/BountyHunter/S0/Ability_E/Ability_E_BountyHunter_ReconDivebomb.Ability_E_BountyHunter_ReconDivebomb_C', '/Game/Characters/BountyHunter/S0/Ability_Q/Ability_Q_BountyHunter_TetherDivebomb.Ability_Q_BountyHunter_TetherDivebomb_C', - '/Game/Characters/BountyHunter/S0/Ability_Q/Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion.Projectile_Q_BountyHunter_TetherGrenade_SphereExpansion_C', '/Game/Characters/BountyHunter/S0/Ability_X/Ability_BountyHunter_X_WaveForm.Ability_BountyHunter_X_WaveForm_C', '/Game/Characters/BountyHunter/S0/Global/Trails/Path_BountyHunter_Trail.Path_BountyHunter_Trail_C', '/Game/Characters/Breach/S0/Ability_4/Ability_Breach_4_FusionBlast.Ability_Breach_4_FusionBlast_C', - '/Game/Characters/Breach/S0/Ability_4/Projectile_Breach_4_FusionBlast.Projectile_Breach_4_FusionBlast_C', '/Game/Characters/Breach/S0/Ability_E/Ability_Breach_E_Fissure.Ability_Breach_E_Fissure_C', '/Game/Characters/Breach/S0/Ability_Q/Ability_Breach_Q_Flash.Ability_Breach_Q_Flash_C', '/Game/Characters/Breach/S0/Ability_X/Ability_Breach_X_Shockwave.Ability_Breach_X_Shockwave_C', '/Game/Characters/Cable/S0/Ability_4/Ability_Cable_4_NetToss.Ability_Cable_4_NetToss_C', '/Game/Characters/Cable/S0/Ability_4/NetTossRemovableDebuff.NetTossRemovableDebuff_C', - '/Game/Characters/Cable/S0/Ability_4/Projectile_NetToss.Projectile_NetToss_C', '/Game/Characters/Cable/S0/Ability_E/Ability_Cable_E_CableJam.Ability_Cable_E_CableJam_C', '/Game/Characters/Cable/S0/Ability_E/GameObject_CableJam_CableDeployer_Precomputed.GameObject_CableJam_CableDeployer_Precomputed_C', - '/Game/Characters/Cable/S0/Ability_E/Projectile_CableJam_InAir.Projectile_CableJam_InAir_C', '/Game/Characters/Cable/S0/Ability_Q/Ability_Cable_Q_SoundSensor.Ability_Cable_Q_SoundSensor_C', '/Game/Characters/Cable/S0/Ability_X/Ability_Cable_X_FishingHook.Ability_Cable_X_FishingHook_C', '/Game/Characters/Cable/S0/Ability_X/Actor_FishingHook.Actor_FishingHook_C', @@ -728,16 +765,12 @@ const observedIgnored = [ '/Game/Characters/Gumshoe/S0/Ability_Q/Projectile_Gumshoe_Q_CameraTrackingDart.Projectile_Gumshoe_Q_CameraTrackingDart_C', '/Game/Characters/Gumshoe/S0/Ability_X/Ability_Gumshoe_X_InterrogateV2.Ability_Gumshoe_X_InterrogateV2_C', '/Game/Characters/Hunter/S0/Ability_4/Ability_Hunter_4_BoltExplosive.Ability_Hunter_4_BoltExplosive_C', - '/Game/Characters/Hunter/S0/Ability_4/Projectile_Hunter_4_ExplosiveBolt.Projectile_Hunter_4_ExplosiveBolt_C', '/Game/Characters/Hunter/S0/Ability_E/Drone/Ability_Hunter_E_DeployDrone.Ability_Hunter_E_DeployDrone_C', '/Game/Characters/Hunter/S0/Ability_E/Drone/Ability_Hunter_E_Drone_Abilities.Ability_Hunter_E_Drone_Abilities_C', '/Game/Characters/Hunter/S0/Ability_Q/Ability_Hunter_Q_RevealBolt_Signature.Ability_Hunter_Q_RevealBolt_Signature_C', '/Game/Characters/Hunter/S0/Ability_Q/GameObject_Hunter_Q_SonarPing.GameObject_Hunter_Q_SonarPing_C', - '/Game/Characters/Hunter/S0/Ability_Q/Projectile_Hunter_Q_RevealBolt.Projectile_Hunter_Q_RevealBolt_C', '/Game/Characters/Hunter/S0/Ability_X/Ability_Hunter_X_LaserMulti.Ability_Hunter_X_LaserMulti_C', '/Game/Characters/Iris/S0/Ability_4/Ability_Iris_Thumper.Ability_Iris_Thumper_C', - '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Concuss.Projectile_Thumper_Concuss_C', - '/Game/Characters/Iris/S0/Ability_4/Projectile_Thumper_Heal.Projectile_Thumper_Heal_C', '/Game/Characters/Iris/S0/Ability_E/Ability_Iris_E_MT_Smoke_Production.Ability_Iris_E_MT_Smoke_Production_C', '/Game/Characters/Iris/S0/Ability_Q/Ability_Iris_Harmonize.Ability_Iris_Harmonize_C', '/Game/Characters/Iris/S0/Ability_X/Ability_Iris_X_SonicWave.Ability_Iris_X_SonicWave_C', @@ -748,25 +781,18 @@ const observedIgnored = [ '/Game/Characters/Killjoy/S0/Ability_X/Ability_Killjoy_X_Bomb.Ability_Killjoy_X_Bomb_C', '/Game/Characters/Killjoy/S0/Ability_X/GameObject_Killjoy_X_Shockwave.GameObject_Killjoy_X_Shockwave_C', '/Game/Characters/Nox/S0/Ability_4/Ability_Nox_BarbedWire.Ability_Nox_BarbedWire_C', - '/Game/Characters/Nox/S0/Ability_4/Projectile_Nox_BarbedWire.Projectile_Nox_BarbedWire_C', '/Game/Characters/Nox/S0/Ability_E/Ability_Nox_FlashTrap.Ability_Nox_FlashTrap_C', '/Game/Characters/Nox/S0/Ability_Q/Ability_Nox_Wall.Ability_Nox_Wall_C', '/Game/Characters/Nox/S0/Ability_X/Ability_Nox_DisarmPulse.Ability_Nox_DisarmPulse_C', '/Game/Characters/Phoenix/S0/Ability_4/Production/Ability_Phoenix_4_Molotov_Production.Ability_Phoenix_4_Molotov_Production_C', - '/Game/Characters/Phoenix/S0/Ability_4/Production/Projectile_Phoenix_4_Molotov_Production.Projectile_Phoenix_4_Molotov_Production_C', '/Game/Characters/Phoenix/S0/Ability_E/Production/Ability_Phoenix_E_FlareCurve_Production.Ability_Phoenix_E_FlareCurve_Production_C', - '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced.Projectile_Phoenix_E_FlareCurve_Synced_C', - '/Game/Characters/Phoenix/S0/Ability_E/Production/Projectile_Phoenix_E_FlareCurve_Synced_Right.Projectile_Phoenix_E_FlareCurve_Synced_Right_C', '/Game/Characters/Phoenix/S0/Ability_Q/Production/Ability_Phoenix_Q_FireballWall_Production.Ability_Phoenix_Q_FireballWall_Production_C', - '/Game/Characters/Phoenix/S0/Ability_Q/Production/Projectile_Phoenix_Q_FlameWall_ThroughWall.Projectile_Phoenix_Q_FlameWall_ThroughWall_C', '/Game/Characters/Phoenix/S0/Ability_X/Production/Ability_Phoenix_X_SelfRes_Production.Ability_Phoenix_X_SelfRes_Production_C', '/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/Ability_Sarge_4_MapTargetSmoke_Production.Ability_Sarge_4_MapTargetSmoke_Production_C', '/Game/Characters/Sarge/S0/Ability_MapTargetSmoke/GameObject_Sarge_4_SmokeManager_Production.GameObject_Sarge_4_SmokeManager_Production_C', '/Game/Characters/Sarge/S0/Ability_Molotov/Ability_Sarge_Q_Molotov_Production.Ability_Sarge_Q_Molotov_Production_C', - '/Game/Characters/Sarge/S0/Ability_Molotov/Projectile_Sarge_Q_Molotov_Production.Projectile_Sarge_Q_Molotov_Production_C', '/Game/Characters/Sarge/S0/Ability_OrbitalStrike/Ability_Sarge_X_OrbitalStrike.Ability_Sarge_X_OrbitalStrike_C', '/Game/Characters/Sarge/S0/Ability_SpeedStim/Ability_Sarge_E_SpeedStim.Ability_Sarge_E_SpeedStim_C', - '/Game/Characters/Sarge/S0/Ability_SpeedStim/Projectile_Sarge_E_SpeedStim.Projectile_Sarge_E_SpeedStim_C', '/Game/Characters/Sequoia/S0/Ability_4/Ability_Sequoia_4_Wave.Ability_Sequoia_4_Wave_C', '/Game/Characters/Sequoia/S0/Ability_E/WreckingBall/Ability_Sequoia_E_WreckingBall.Ability_Sequoia_E_WreckingBall_C', '/Game/Characters/Sequoia/S0/Ability_E/WreckingBall/GameObject_Sequoia_E_Shield.GameObject_Sequoia_E_Shield_C', @@ -779,7 +805,6 @@ const observedIgnored = [ '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/Ability_Smonk_E_MapTargetSmokeV2.Ability_Smonk_E_MapTargetSmokeV2_C', '/Game/Characters/Smonk/S0/Ability_E/MapTargetSmoke/Ability_Smonk_E_PostDeath.Ability_Smonk_E_PostDeath_C', '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/Ability_Q_Smonk_DebuffKnife.Ability_Q_Smonk_DebuffKnife_C', - '/Game/Characters/Smonk/S0/Ability_Q/DebuffKnife/DecayLauncher/Projectile_Smonk_DecayNade.Projectile_Smonk_DecayNade_C', '/Game/Characters/Smonk/S0/Ability_X/ReactiveRes/Ability_Smonk_X_PostDeath_ReactiveResStart.Ability_Smonk_X_PostDeath_ReactiveResStart_C', '/Game/Characters/Smonk/S0/Ability_X/ReactiveRes/Ability_Smonk_X_ReactiveRes_Engineering.Ability_Smonk_X_ReactiveRes_Engineering_C', '/Game/Characters/Smonk/S0/Global/PostDeath/Equippable_Smonk_PostDeath_Unarmed.Equippable_Smonk_PostDeath_Unarmed_C', @@ -797,26 +822,22 @@ const observedIgnored = [ '/Game/Characters/Stealth/S0/Ability_Q/Ability_Stealth_Q_BounceFlash.Ability_Stealth_Q_BounceFlash_C', '/Game/Characters/Stealth/S0/Ability_X/Ability_Stealth_X_Cloak_Equip.Ability_Stealth_X_Cloak_Equip_C', '/Game/Characters/Thorne/S0/Ability_4/Ability_Thorne_4_SlowField_Production.Ability_Thorne_4_SlowField_Production_C', - '/Game/Characters/Thorne/S0/Ability_4/Projectile_Thorne_4_SlowFIeld_Production.Projectile_Thorne_4_SlowFIeld_Production_C', '/Game/Characters/Thorne/S0/Ability_E/Ability_Thorne_E_Wall_Fortifying.Ability_Thorne_E_Wall_Fortifying_C', '/Game/Characters/Thorne/S0/Ability_E/GameObject_Thorne_E_Wall_Segment_Fortifying.GameObject_Thorne_E_Wall_Segment_Fortifying_C', '/Game/Characters/Thorne/S0/Ability_Q/Ability_Thorne_Q_Heal_Production_New.Ability_Thorne_Q_Heal_Production_New_C', '/Game/Characters/Thorne/S0/Ability_X/Ability_Thorne_X_Resurrect_Production.Ability_Thorne_X_Resurrect_Production_C', '/Game/Characters/Vampire/S0/Ability_4/Ability_Vampire_4_NearsightAoE.Ability_Vampire_4_NearsightAoE_C', - '/Game/Characters/Vampire/S0/Ability_4/Projectile_Vampire_4_NearsightAoE.Projectile_Vampire_4_NearsightAoE_C', '/Game/Characters/Vampire/S0/Ability_E/Ability_Vampire_E_Escape.Ability_Vampire_E_Escape_C', '/Game/Characters/Vampire/S0/Ability_Q/Ability_Vampire_Q_Heal.Ability_Vampire_Q_Heal_C', '/Game/Characters/Vampire/S0/Ability_Q/GameObject_Vampire_Q_Heal_HealPool_AutoActivate.GameObject_Vampire_Q_Heal_HealPool_AutoActivate_C', '/Game/Characters/Vampire/S0/Ability_Q/GameObject_Vampire_Q_Heal_HealPool_High.GameObject_Vampire_Q_Heal_HealPool_High_C', '/Game/Characters/Vampire/S0/Ability_X/Ability_Vampire_X_Frenzy.Ability_Vampire_X_Frenzy_C', '/Game/Characters/Wraith/S0/Ability_4/Ability_Wraith_4_Smoke.Ability_Wraith_4_Smoke_C', - '/Game/Characters/Wraith/S0/Ability_4/Projectile_Wraith_4_Smoke.Projectile_Wraith_4_Smoke_C', '/Game/Characters/Wraith/S0/Ability_E/Ability_Wraith_E_ShortTeleport.Ability_Wraith_E_ShortTeleport_C', '/Game/Characters/Wraith/S0/Ability_Q/Ability_Wraith_Q_NearsightMissile.Ability_Wraith_Q_NearsightMissile_C', '/Game/Characters/Wraith/S0/Ability_Q/GameObject_Wraith_Q_NearsightMissile_TrajectoryWarning.GameObject_Wraith_Q_NearsightMissile_TrajectoryWarning_C', '/Game/Characters/Wraith/S0/Ability_X/Ability_Wraith_X_GlobalTeleport.Ability_Wraith_X_GlobalTeleport_C', '/Game/Characters/Wushu/S0/Ability_4/Ability_Wushu_4_Smoke.Ability_Wushu_4_Smoke_C', - '/Game/Characters/Wushu/S0/Ability_4/Projectile_Wushu_4_Smoke.Projectile_Wushu_4_Smoke_C', '/Game/Characters/Wushu/S0/Ability_E/Ability_Wushu_E_Dash.Ability_Wushu_E_Dash_C', '/Game/Characters/Wushu/S0/Ability_Q/Ability_Wushu_Q_CycloneBoost.Ability_Wushu_Q_CycloneBoost_C', '/Game/Characters/Wushu/S0/Ability_X/Ability_Wushu_X_Dagger_Production.Ability_Wushu_X_Dagger_Production_C', diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index cb6fb51ae..01f31d2bf 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -1,12 +1,19 @@ +import 'dart:typed_data'; +import 'dart:io'; +import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/replay/replay_cone_cuts.dart'; +import 'package:icarus/replay/replay_cone_worker.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/view_cone/vision_occluders.dart'; import 'replay_test_support.dart'; @@ -251,6 +258,67 @@ void main() { }); }); + group('utility', () { + const omenOrb = + '/Game/Characters/Wraith/S0/Ability_4/Projectile_Wraith_4_Smoke.Projectile_Wraith_4_Smoke_C'; + Map smoke({int spawnMs = 1000, int? endMs = 9000}) => { + 'id': 1, + 'classPath': omenSmoke, + 'owner': 'b', + 'spawnMs': spawnMs, + 'endMs': endMs, + 'position': [0, 0, 100], + }; + + test('a smoke blocks sight while it is up', () { + final frames = builder(document(utility: [smoke()])); + final up = frames.frameAt(2000, perspective: ReplayTeam.red); + expect(up.occluders.single, isA()); + expect((up.occluders.single as CircleOccluder).radius, greaterThan(0)); + expect( + frames.frameAt(9600, perspective: ReplayTeam.red).occluders, isEmpty); + }); + + test('a utility rings as it activates and fades out as it ends', () { + final frames = builder(document(utility: [smoke()])); + ReplayFrame at(int t) => frames.frameAt(t, perspective: ReplayTeam.red); + expect(at(1100).effects.whereType(), hasLength(1)); + expect(at(2000).effects, isEmpty); + // Ended at 9000: drawn fading for half a second, then gone, and never + // part of what a capture saves. + final leaving = at(9200); + expect(leaving.abilities, isEmpty); + final exit = leaving.effects.whereType().single; + expect(exit.progress, closeTo(0.4, 1e-9)); + expect(at(9600).effects, isEmpty); + }); + + test('a thrown smoke flies as its icon, not as the smoke', () { + final frames = builder(document(utility: [ + { + 'id': 2, + 'classPath': omenOrb, + 'owner': 'b', + 'spawnMs': 1000, + 'endMs': 2000, + 'position': [0, 0, 100], + 'path': [ + [1500, 500, 0, 100], + [2000, 1000, 0, 100], + ], + }, + ])); + final frame = frames.frameAt(1250, perspective: ReplayTeam.red); + expect(frame.abilities, isEmpty); + final flight = frame.effects.whereType().single; + // Half way to its first path point: the path is followed smoothly. + final start = projection.toWorld(0, 0); + final first = projection.toWorld(500, 0); + expect((flight.trail.last - Offset.lerp(start, first, 0.5)!).distance, + lessThan(1e-6)); + }); + }); + test('size conversions need no laid-out canvas, and match the canvas', () { for (final height in [700.0, 1440.0]) { final canvas = CoordinateSystem( @@ -292,24 +360,25 @@ void main() { expect(p.timeMs, 0); }); - test('playing moves a few players a frame; pausing shows all exactly', () { + ReplayDocument walkers(List subjects) => document( + players: [ + for (final subject in subjects) + {'subject': subject, 'agentId': jett, 'team': 'Red'}, + ], + movement: { + // Everyone walks along x, 1 cm a millisecond. + for (final subject in subjects) + subject: [ + for (var t = 0; t <= 60000; t += 50) + [t, t.toDouble(), 0, 100, 0, 0] + ], + }, + ); + + test('playing moves every player every frame', () { final subjects = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5']; - final doc = document( - players: [ - for (final subject in subjects) - {'subject': subject, 'agentId': jett, 'team': 'Red'}, - ], - movement: { - // Everyone walks along x, 1 cm a millisecond. - for (final subject in subjects) - subject: [ - for (var t = 0; t <= 60000; t += 50) - [t, t.toDouble(), 0, 100, 0, 0] - ], - }, - ); final playback = ReplayPlayback( - document: doc, + document: walkers(subjects), projection: projection, perspective: ReplayTeam.red, )..play(); @@ -319,19 +388,71 @@ void main() { final before = positions(); playback.advance(const Duration(milliseconds: 10)); final after = positions(); - final moved = subjects - .where((s) => before['replay-player-$s'] != after['replay-player-$s']) - .length; - expect(moved, 3); - - playback.pause(); - final exact = playback.frames - .frameAt(playback.timeMs, perspective: ReplayTeam.red) - .agents; expect( - {for (final agent in playback.frame.agents) agent.id: agent.position}, - {for (final agent in exact) agent.id: agent.position}, + subjects.where( + (s) => before['replay-player-$s'] != after['replay-player-$s']), + hasLength(subjects.length), + ); + // With no cone worker, each cone cuts its own. + expect(playback.frame.cones, isEmpty); + }); + + test('cones are cut on a worker and drawn from their latest cut', () async { + CoordinateSystem(playAreaSize: const Size(1600, 900)); + List model(String side) => + File('assets/maps/sunset_svg_height_$side.json.gz').readAsBytesSync(); + final worker = await ReplayConeWorker.start( + attackModel: Uint8List.fromList(model('attack')), + defenseModel: Uint8List.fromList(model('defense')), + ); + addTearDown(worker.dispose); + final playback = ReplayPlayback( + document: walkers(['p0']), + projection: projection, + perspective: ReplayTeam.red, + ); + var arrived = 0; + final cuts = ReplayConeCuts( + worker: worker, + map: projection.map, + attackModel: SvgHeightVisibility.fromJson( + jsonDecode(utf8.decode(gzip.decode(model('attack')))) + as Map), + defenseModel: SvgHeightVisibility.fromJson( + jsonDecode(utf8.decode(gzip.decode(model('defense')))) + as Map), + coneLength: ReplayFrameBuilder.coneLength, + apertureDegrees: 180, + onCut: () { + arrived++; + playback.coneCutArrived(); + }, ); + playback + ..coneCuts = cuts + ..seek(1000); + + // Asked for, not yet cut: no cone rather than a wrong one. + final waiting = playback.frame.cones.values.single; + expect(waiting.cut, isNull); + while (arrived == 0) { + await Future.delayed(const Duration(milliseconds: 5)); + } + final cut = playback.frame.cones.values.single; + expect(cut.cut!.cone!.polygon.length, greaterThanOrEqualTo(3)); + expect(cut.cut!.aim, cut.aim, reason: 'paused: cut where they stand'); + + // Moving on, the agent moves at once; the old cut is drawn carried + // along to it until the next one arrives. + playback.seek(1500); + final moved = playback.frame.cones.values.single; + expect(moved.cut!.id, cut.cut!.id); + expect(moved.aim.origin, isNot(moved.cut!.aim.origin)); + while (arrived == 1) { + await Future.delayed(const Duration(milliseconds: 5)); + } + final caughtUp = playback.frame.cones.values.single; + expect(caughtUp.cut!.aim, caughtUp.aim); }); test('playing stops at the end', () { diff --git a/test/vision_occluders_test.dart b/test/vision_occluders_test.dart new file mode 100644 index 000000000..4254b1b6f --- /dev/null +++ b/test/vision_occluders_test.dart @@ -0,0 +1,71 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/view_cone/vision_occluders.dart'; + +void main() { + const smoke = CircleOccluder(Offset(40, 0), 10); + + test('a smoke hides itself and what lies behind it, nothing beside it', () { + final hidden = occluderShadows(Offset.zero, 100, const [smoke])!; + expect(hidden.contains(const Offset(40, 0)), isTrue, reason: 'inside'); + expect(hidden.contains(const Offset(80, 0)), isTrue, reason: 'behind'); + expect(hidden.contains(const Offset(80, 30)), isFalse, reason: 'beside'); + expect(hidden.contains(const Offset(20, 0)), isFalse, reason: 'in front'); + expect(hidden.contains(const Offset(-50, 0)), isFalse, reason: 'opposite'); + }); + + test('standing in a smoke, or on its rim, hides everything', () { + expect(standsInsideSmoke(const Offset(40, 2), const [smoke]), isTrue); + expect(standsInsideSmoke(const Offset(50.005, 0), const [smoke]), isTrue); + expect(standsInsideSmoke(const Offset(52, 0), const [smoke]), isFalse); + }); + + test('a smoke wall hides what lies behind it', () { + final hidden = occluderShadows(Offset.zero, 100, const [ + LineOccluder([Offset(30, -20), Offset(30, 0), Offset(30, 20)]) + ])!; + expect(hidden.contains(const Offset(60, 10)), isTrue); + expect(hidden.contains(const Offset(60, -10)), isTrue); + expect(hidden.contains(const Offset(20, 10)), isFalse); + expect(hidden.contains(const Offset(60, 60)), isFalse); + }); + + test('overlapping smokes both hide, whichever way they wind', () { + final hidden = occluderShadows(Offset.zero, 100, const [ + smoke, + CircleOccluder(Offset(45, 5), 10), + LineOccluder([Offset(30, 20), Offset(30, -20)]), + ])!; + for (final point in const [Offset(70, 0), Offset(70, 8), Offset(50, 5)]) { + expect(hidden.contains(point), isTrue, reason: '$point'); + } + }); + + test('a smoke right beside the eye still has a bounded shadow', () { + final hidden = occluderShadows(const Offset(29.95, 0), 100, const [smoke])!; + expect(hidden.getBounds().width, lessThan(1e6)); + expect(hidden.contains(const Offset(80, 0)), isTrue); + }); + + test('nothing to hide gives no shadow', () { + expect(occluderShadows(Offset.zero, 100, const []), isNull); + expect( + occluderShadows(Offset.zero, 100, const [ + LineOccluder([Offset(10, 0), Offset(20, 0)]), + ]), + isNull, + reason: 'a wall seen edge on', + ); + }); + + test('only occluders within reach reach the cone', () { + expect(smoke.reaches(Offset.zero, 25), isFalse); + expect(smoke.reaches(Offset.zero, 35), isTrue); + expect( + const LineOccluder([Offset(0, 50), Offset(100, 50)]) + .reaches(Offset.zero, 40), + isFalse, + ); + }); +} From d53363626e0b6b6280f29e5ed223fbce2cb1c230 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 19:06:28 -0400 Subject: [PATCH 11/18] Review fixes: smoke shadows stay put, cones stand on their agent From Astra's review of the replay cones and effects: - Smoke shadows are judged from where the agent stands now and erased in map space, so a smoke's shadow no longer slides or turns with a cone whose cut lags the agent by a frame. - A wall's shadow closes along an arc past the cone's reach; a straight far edge between its ends cut back inside the reach when the wall was close, leaving what lay behind it visible. - A cone whose cut was made from a point nudged out of wall ink is drawn with its apex on the agent, as the editor draws it. - The cached utility layer follows the canvas size. - A worker whose height models fail to load reports it instead of leaving the viewer waiting on it, and a failed cut is not asked for again in a loop; the frame is redrawn without it. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/replay/replay_cone_cuts.dart | 13 ++++++++- lib/replay/replay_cone_worker.dart | 21 ++++++++++++-- lib/view_cone/vision_occluders.dart | 17 ++++++++++-- lib/widgets/replay/replay_canvas.dart | 2 +- lib/widgets/replay/replay_cones.dart | 40 +++++++++++++++------------ test/replay_frame_test.dart | 10 +++++++ test/vision_occluders_test.dart | 10 +++++++ 7 files changed, 87 insertions(+), 26 deletions(-) diff --git a/lib/replay/replay_cone_cuts.dart b/lib/replay/replay_cone_cuts.dart index 1850d1629..a3f08a44e 100644 --- a/lib/replay/replay_cone_cuts.dart +++ b/lib/replay/replay_cone_cuts.dart @@ -81,6 +81,10 @@ class ReplayConeCuts { final _latest = {}; final _waiting = {}; + + /// The aim each player's last cut failed at, so the same one is not asked + /// for again and again while paused. + final _failed = {}; var _nextId = 0; var _disposed = false; @@ -89,7 +93,11 @@ class ReplayConeCuts { /// Asks for [subject]'s cone at [aim], unless its latest cut is already /// there or another is on its way. void want(String subject, ReplayConeAim aim) { - if (_waiting.contains(subject) || _latest[subject]?.aim == aim) return; + if (_waiting.contains(subject) || + _latest[subject]?.aim == aim || + _failed[subject] == aim) { + return; + } _waiting.add(subject); worker.cut(_request(aim)).then((result) { _waiting.remove(subject); @@ -103,6 +111,9 @@ class ReplayConeCuts { onCut(); }, onError: (Object _) { _waiting.remove(subject); + if (_disposed) return; + _failed[subject] = aim; + onCut(); }); } diff --git a/lib/replay/replay_cone_worker.dart b/lib/replay/replay_cone_worker.dart index b72420b78..7ceee560b 100644 --- a/lib/replay/replay_cone_worker.dart +++ b/lib/replay/replay_cone_worker.dart @@ -69,6 +69,9 @@ class ReplayConeWorker { responses.listen((message) { if (message is SendPort) { ready.complete(message); + } else if (message is String) { + // The models did not load; the worker has stopped. + if (!ready.isCompleted) ready.completeError(StateError(message)); } else if (message is List && message.length == 2) { worker._answer(message[0] as int, message[1]); } else if (message is List && message.length == 3) { @@ -86,7 +89,13 @@ class ReplayConeWorker { errorsAreFatal: false, debugName: 'replay cone worker', ); - worker = ReplayConeWorker._(isolate, await ready.future, responses); + try { + worker = ReplayConeWorker._(isolate, await ready.future, responses); + } catch (_) { + responses.close(); + isolate.kill(priority: Isolate.immediate); + rethrow; + } return worker; } @@ -160,8 +169,14 @@ SvgHeightVisibility _model(TransferableTypedData bytes) { void _workerMain( (SendPort, TransferableTypedData, TransferableTypedData) setup) { final (replies, attackBytes, defenseBytes) = setup; - final attack = _model(attackBytes); - final defense = _model(defenseBytes); + final SvgHeightVisibility attack, defense; + try { + attack = _model(attackBytes); + defense = _model(defenseBytes); + } catch (error) { + replies.send('Could not load the height models: $error'); + return; + } final requests = ReceivePort(); replies.send(requests.sendPort); requests.listen((message) { diff --git a/lib/view_cone/vision_occluders.dart b/lib/view_cone/vision_occluders.dart index 1f43b7325..ab5a141ea 100644 --- a/lib/view_cone/vision_occluders.dart +++ b/lib/view_cone/vision_occluders.dart @@ -104,12 +104,23 @@ class LineOccluder extends VisionOccluder { final toA = a - origin, toB = b - origin; // Seen edge on, a segment hides nothing. if ((toA.dx * toB.dy - toA.dy * toB.dx).abs() < 1e-9) continue; - final far = reach + math.max(toA.distance, toB.distance); + // Out along both ends, then round between them at a radius past + // the cone's reach: a straight far edge between the two ends cuts + // back inside the reach when the wall is close. + final far = (reach + math.max(toA.distance, toB.distance)) * 2; + final fromAngle = math.atan2(toA.dy, toA.dx); + var sweep = math.atan2(toB.dy, toB.dx) - fromAngle; + if (sweep > math.pi) sweep -= 2 * math.pi; + if (sweep < -math.pi) sweep += 2 * math.pi; + final steps = math.max(1, (sweep.abs() / (math.pi / 36)).ceil()); out.add([ a, b, - origin + toB / toB.distance * (far * 2), - origin + toA / toA.distance * (far * 2), + for (var step = steps; step >= 0; step--) + origin + + Offset(math.cos(fromAngle + sweep * step / steps), + math.sin(fromAngle + sweep * step / steps)) * + far, ]); } return out; diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 8be1c8e00..68f87cdfa 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -176,7 +176,7 @@ class _ReplayCanvasState extends ConsumerState { Positioned.fill( child: _UtilityLayer( // How it is drawn, beyond the pieces themselves. - look: (isAttack, abilitySize, mapScale), + look: (isAttack, abilitySize, mapScale, height), placed: [ for (final placed in widgets) if (placed is! PlacedAgentNode) placed, diff --git a/lib/widgets/replay/replay_cones.dart b/lib/widgets/replay/replay_cones.dart index fa6434c16..611aa19d6 100644 --- a/lib/widgets/replay/replay_cones.dart +++ b/lib/widgets/replay/replay_cones.dart @@ -63,29 +63,30 @@ class ReplayConesPainter extends CustomPainter { final origin = cut?.origin; if (cut == null || cone == null || origin == null) continue; if (cone.polygon.length < 3) continue; - // Carry the cut to where its agent is now, and turn it with them. - final moved = toScreen(_source(aim.origin, transform)) - - toScreen(_source(cut.aim.origin, transform)); + final reach = _range(transform); + // Where the agent stands now, in height-model space. Smokes do not + // move with a lagging cut, so they are judged from here. + final standing = _source(aim.origin, transform); + final shadows = + occluders.isEmpty ? null : _shadows(standing, reach, transform); + if (shadows == _blind) continue; + // The cut's origin lands on the agent, turned to their facing: as the + // editor draws a cone, whose apex is the agent even when its cut was + // made from a point nudged out of wall ink. + final apex = toScreen(standing); final turn = coordinates.rotationForSide(aim.rotation, isAttack: isAttack) - coordinates.rotationForSide(cut.aim.rotation, isAttack: isAttack); - final apex = toScreen(origin) + moved; - + final from = toScreen(origin); + if (shadows != null) { + canvas.saveLayer( + Rect.fromCircle(center: apex, radius: reach * scale), Paint()); + } canvas.save(); canvas.translate(apex.dx, apex.dy); canvas.rotate(turn); - canvas.translate(-apex.dx + moved.dx, -apex.dy + moved.dy); + canvas.translate(-from.dx, -from.dy); canvas.transform(sourceToScreen); - - final reach = _range(transform); - final shadows = - occluders.isEmpty ? null : _shadows(origin, reach, transform); - if (shadows == _blind) { - canvas.restore(); - continue; - } - final bounds = Rect.fromCircle(center: origin, radius: reach); - if (shadows != null) canvas.saveLayer(bounds, Paint()); // The cut lies within reach, so filling it with the editor's gradient // is the editor's clipped circle, without a clip mask per cone. canvas.drawPath( @@ -93,13 +94,16 @@ class ReplayConesPainter extends CustomPainter { Paint() ..shader = RadialGradient( colors: [_coneGrey.withValues(alpha: .5), Colors.transparent], - ).createShader(bounds), + ).createShader(Rect.fromCircle(center: origin, radius: reach)), ); + canvas.restore(); if (shadows != null) { + canvas.save(); + canvas.transform(sourceToScreen); canvas.drawPath(shadows, Paint()..blendMode = BlendMode.clear); canvas.restore(); + canvas.restore(); } - canvas.restore(); } canvas.restore(); } diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index 01f31d2bf..8369baa5a 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -455,6 +455,16 @@ void main() { expect(caughtUp.cut!.aim, caughtUp.aim); }); + test('a worker whose models do not load says so', () async { + await expectLater( + ReplayConeWorker.start( + attackModel: Uint8List.fromList([1, 2, 3]), + defenseModel: Uint8List.fromList([4, 5, 6]), + ), + throwsStateError, + ); + }); + test('playing stops at the end', () { final p = playback(); p diff --git a/test/vision_occluders_test.dart b/test/vision_occluders_test.dart index 4254b1b6f..ab87b0fae 100644 --- a/test/vision_occluders_test.dart +++ b/test/vision_occluders_test.dart @@ -31,6 +31,16 @@ void main() { expect(hidden.contains(const Offset(60, 60)), isFalse); }); + test('a smoke wall right in front of the eye hides all the way out', () { + final hidden = occluderShadows(Offset.zero, 100, const [ + LineOccluder([Offset(1, -10), Offset(1, 10)]) + ])!; + for (final point in const [Offset(50, 0), Offset(95, 0), Offset(95, 40)]) { + expect(hidden.contains(point), isTrue, reason: '$point'); + } + expect(hidden.contains(const Offset(-50, 0)), isFalse); + }); + test('overlapping smokes both hide, whichever way they wind', () { final hidden = occluderShadows(Offset.zero, 100, const [ smoke, From e44254c2fe16f6de420ce28f22334001be0db9df Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 22:19:44 -0400 Subject: [PATCH 12/18] Replay: cut cones all the way round, face them when painting A cut was made for one facing and turned with the agent until the next arrived, so a fast turn swung a stale cut through walls. Cuts now see all the way round from where the agent stands, and the painter keeps the wedge the agent faces. Turning never waits for a cut; only moving does. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/replay/replay_cone_cuts.dart | 17 +++++++++--- lib/widgets/replay/replay_canvas.dart | 1 + lib/widgets/replay/replay_cones.dart | 37 ++++++++++++++++++++------- test/replay_frame_test.dart | 5 ++-- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/lib/replay/replay_cone_cuts.dart b/lib/replay/replay_cone_cuts.dart index 7575f11eb..6406d2afa 100644 --- a/lib/replay/replay_cone_cuts.dart +++ b/lib/replay/replay_cone_cuts.dart @@ -8,7 +8,7 @@ import 'package:icarus/replay/replay_cone_worker.dart'; import 'package:icarus/view_cone/svg_height_visibility.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; -/// Where a player's view cone is aimed: everything its cut depends on. +/// Where a player's view cone is aimed. @immutable class ReplayConeAim { const ReplayConeAim({ @@ -36,6 +36,15 @@ class ReplayConeAim { @override int get hashCode => Object.hash(origin, rotation, isAttack, elevationCm); + + /// Everything a cut depends on. A cut sees all the way round, so where the + /// player faces is applied when the cone is painted: turning never waits + /// for a cut, and a cut never swings through a wall while one is made. + ReplayConeAim get placed => ReplayConeAim( + origin: origin, + rotation: 0, + isAttack: isAttack, + elevationCm: elevationCm); } /// A finished cut: the [aim] it was made for, where the cone stands in @@ -70,10 +79,11 @@ class ReplayConeCuts { final ReplayConeWorker worker; final MapValue map; - /// For the reviewed-floor pass, which only the root isolate can run. + /// The models the worker cuts against, for painting what it cut. final SvgHeightVisibility attackModel, defenseModel; /// The cones' length and spread, as the editor's view cones take them. + /// Cuts see all the way round; the spread is applied when painting. final double coneLength, apertureDegrees; /// Called when a cut arrives, so a paused frame can show it. @@ -93,6 +103,7 @@ class ReplayConeCuts { /// Asks for [subject]'s cone at [aim], unless its latest cut is already /// there or another is on its way. void want(String subject, ReplayConeAim aim) { + aim = aim.placed; if (_waiting.contains(subject) || _latest[subject]?.aim == aim || _failed[subject] == aim) { @@ -134,7 +145,7 @@ class ReplayConeCuts { directionRadians: rotation - math.pi / 2, range: CoordinateSystem.virtualLengthInWorld(coneLength) / transform.scale, - apertureRadians: apertureDegrees * math.pi / 180, + apertureRadians: 2 * math.pi, elevationCm: aim.elevationCm, ); } diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 68f87cdfa..9128a4556 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -221,6 +221,7 @@ class _ReplayCanvasState extends ConsumerState { : cuts.defenseModel, map: widget.map, isAttack: isAttack, + apertureDegrees: cuts.apertureDegrees, ), ), ), diff --git a/lib/widgets/replay/replay_cones.dart b/lib/widgets/replay/replay_cones.dart index af1a5db3f..82c64699b 100644 --- a/lib/widgets/replay/replay_cones.dart +++ b/lib/widgets/replay/replay_cones.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -20,6 +21,7 @@ class ReplayConesPainter extends CustomPainter { required this.model, required this.map, required this.isAttack, + required this.apertureDegrees, }); final Iterable cones; @@ -30,6 +32,9 @@ class ReplayConesPainter extends CustomPainter { final MapValue map; final bool isAttack; + /// The cones' spread; cuts see all the way round. + final double apertureDegrees; + static final _conePaths = Expando(); static final _floors = Expando<(Size, Float64List, Path)>(); @@ -70,22 +75,22 @@ class ReplayConesPainter extends CustomPainter { final shadows = occluders.isEmpty ? null : _shadows(standing, reach, transform); if (shadows == _blind) continue; - // The cut's origin lands on the agent, turned to their facing: as the - // editor draws a cone, whose apex is the agent even when its cut was - // made from a point nudged out of wall ink. + // The cut's origin lands on the agent, as the editor draws a cone, + // whose apex is the agent even when its cut was made from a point + // nudged out of wall ink. The cut sees all the way round; the wedge + // the agent faces is cut from it here, so turning is never stale. final apex = toScreen(standing); - final turn = - coordinates.rotationForSide(aim.rotation, isAttack: isAttack) - - coordinates.rotationForSide(cut.aim.rotation, isAttack: isAttack); final from = toScreen(origin); + final facing = + coordinates.rotationForSide(aim.rotation, isAttack: isAttack) - + math.pi / 2; if (shadows != null) { canvas.saveLayer( Rect.fromCircle(center: apex, radius: reach * scale), Paint()); } canvas.save(); - canvas.translate(apex.dx, apex.dy); - canvas.rotate(turn); - canvas.translate(-from.dx, -from.dy); + canvas.clipPath(_wedge(apex, facing, reach * scale)); + canvas.translate(apex.dx - from.dx, apex.dy - from.dy); canvas.transform(sourceToScreen); // The cut lies within reach, so filling it with the editor's gradient // is the editor's clipped circle, without a clip mask per cone. @@ -112,6 +117,20 @@ class ReplayConesPainter extends CustomPainter { canvas.restore(); } + /// The sector an agent at [apex] facing [facing] sees, out to [radius]. + Path _wedge(Offset apex, double facing, double radius) { + const steps = 48; + final aperture = apertureDegrees * math.pi / 180; + return Path() + ..addPolygon([ + apex, + for (var i = 0; i <= steps; i++) + apex + + Offset.fromDirection( + facing - aperture / 2 + aperture * i / steps, radius), + ], true); + } + /// A view cone's reach in height-model space. static double _range(SvgHeightMapTransform transform) => CoordinateSystem.virtualLengthInWorld(ReplayFrameBuilder.coneLength) / diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index 8369baa5a..a54b2b038 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -440,7 +440,8 @@ void main() { } final cut = playback.frame.cones.values.single; expect(cut.cut!.cone!.polygon.length, greaterThanOrEqualTo(3)); - expect(cut.cut!.aim, cut.aim, reason: 'paused: cut where they stand'); + expect(cut.cut!.aim, cut.aim.placed, + reason: 'paused: cut where they stand'); // Moving on, the agent moves at once; the old cut is drawn carried // along to it until the next one arrives. @@ -452,7 +453,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 5)); } final caughtUp = playback.frame.cones.values.single; - expect(caughtUp.cut!.aim, caughtUp.aim); + expect(caughtUp.cut!.aim, caughtUp.aim.placed); }); test('a worker whose models do not load says so', () async { From 7d148aac7c71552da02a96e9b5dc588baafead5a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 15:45:06 -0400 Subject: [PATCH 13/18] Block replay sight with each smoke as wide as it is drawn Most smokes (Omen, Harbor, Viper, Astra, Brimstone, Clove, Miks, Jett) are image abilities, and the replay took every non-circle ability's reach as 18 virtual units: they blocked sight with a 36-unit circle whatever their drawn size (46 to 55 for most, 30 for Jett), so cones painted over a smoke's edges. Image abilities now reach half their drawn width, as circles do; each smoke image fills its square to the edge. The smoke test checks the blocking circle against the drawn size. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/replay/replay_frame.dart | 12 ++++++++---- test/replay_frame_test.dart | 10 ++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/replay/replay_frame.dart b/lib/replay/replay_frame.dart index aa365862b..0a0bb44f0 100644 --- a/lib/replay/replay_frame.dart +++ b/lib/replay/replay_frame.dart @@ -474,11 +474,15 @@ class ReplayFrameBuilder { ); } - /// How far an ability reaches from its centre, world units: a circle's - /// radius as drawn, otherwise about an icon's width. + /// How far an ability reaches from its centre, world units: half of a + /// circle or image as drawn (smoke art fills its square to the edge), + /// otherwise about an icon's width. static double _radiusOf(ReplayAbilityEntry entry, double mapScale) { - final shape = entry.ability.abilityData; - final virtual = shape is CircleAbility ? shape.size * mapScale / 2 : 18.0; + final virtual = switch (entry.ability.abilityData) { + CircleAbility(:final size) || ImageAbility(:final size) => + size * mapScale / 2, + _ => 18.0, + }; return CoordinateSystem.virtualLengthInWorld(virtual); } diff --git a/test/replay_frame_test.dart b/test/replay_frame_test.dart index a54b2b038..007bac94c 100644 --- a/test/replay_frame_test.dart +++ b/test/replay_frame_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_cone_worker.dart'; @@ -270,11 +271,16 @@ void main() { 'position': [0, 0, 100], }; - test('a smoke blocks sight while it is up', () { + test('a smoke blocks sight while it is up, as wide as it is drawn', () { final frames = builder(document(utility: [smoke()])); final up = frames.frameAt(2000, perspective: ReplayTeam.red); expect(up.occluders.single, isA()); - expect((up.occluders.single as CircleOccluder).radius, greaterThan(0)); + // Omen's smoke is drawn as an image that fills its square; a cone's + // shadow must leave from the edge of that image, not from inside it. + final drawn = up.abilities.single.data.abilityData! + .getSize(mapScale: Maps.mapScale[projection.map]!, abilitySize: 0); + expect((up.occluders.single as CircleOccluder).radius, + closeTo(CoordinateSystem.virtualLengthInWorld(drawn.dx / 2), 1e-9)); expect( frames.frameAt(9600, perspective: ReplayTeam.red).occluders, isEmpty); }); From 9d842538875507f81150a049a66daec0cf49843a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 15:45:07 -0400 Subject: [PATCH 14/18] Fade a replay ability's range, not its icon The replay drew faint utility (areas, rectangles) by wrapping the whole ability in one Opacity, icon included. AbilityRangeOpacity now tells the range widgets how strongly to draw their outlines, fills, inner ranges, wall bodies and meshes; icons stay solid. Outside it ranges draw at 1, so the editor is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ability/ability_range_opacity.dart | 27 ++++++++++ .../ability/center_square_widget.dart | 4 +- .../ability/custom_circle_widget.dart | 21 ++++++-- .../ability/custom_square_widget.dart | 10 ++-- .../ability/deadlock_barrier_mesh_widget.dart | 4 +- .../ability/resizable_square_widget.dart | 4 +- .../ability/sector_circle_widget.dart | 30 +++++++---- .../ability/simple_image_ability_widget.dart | 6 ++- lib/widgets/replay/replay_canvas.dart | 30 +++++++---- test/ability_visibility_widgets_test.dart | 50 +++++++++++++++++++ 10 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 lib/widgets/draggable_widgets/ability/ability_range_opacity.dart diff --git a/lib/widgets/draggable_widgets/ability/ability_range_opacity.dart b/lib/widgets/draggable_widgets/ability/ability_range_opacity.dart new file mode 100644 index 000000000..0a4241956 --- /dev/null +++ b/lib/widgets/draggable_widgets/ability/ability_range_opacity.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; + +/// How strongly the abilities below draw their ranges: outlines, fills, +/// inner ranges, wall bodies and area art. Their icons are always drawn in +/// full. A replay draws utility ranges faint so the agents and cones beneath +/// stay readable while the icon still says what was thrown. +class AbilityRangeOpacity extends InheritedWidget { + const AbilityRangeOpacity({ + super.key, + required this.opacity, + required super.child, + }); + + final double opacity; + + /// The opacity ranges are drawn at here: 1 outside any + /// [AbilityRangeOpacity]. + static double of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.opacity ?? + 1; + + @override + bool updateShouldNotify(AbilityRangeOpacity oldWidget) => + oldWidget.opacity != opacity; +} diff --git a/lib/widgets/draggable_widgets/ability/center_square_widget.dart b/lib/widgets/draggable_widgets/ability/center_square_widget.dart index 9e04f11c3..4716bbed5 100644 --- a/lib/widgets/draggable_widgets/ability/center_square_widget.dart +++ b/lib/widgets/draggable_widgets/ability/center_square_widget.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class CenterSquareWidget extends ConsumerWidget { @@ -35,6 +36,7 @@ class CenterSquareWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final rangeOpacity = AbilityRangeOpacity.of(context); final coordinateSystem = CoordinateSystem.instance; final abilitySize = ref.watch(strategySettingsProvider).abilitySize; final totalWidth = coordinateSystem.scale(abilitySize); @@ -49,7 +51,7 @@ class CenterSquareWidget extends ConsumerWidget { Positioned( child: Opacity( key: const ValueKey('square-range-body'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, child: Align( alignment: Alignment.center, child: IgnorePointer( diff --git a/lib/widgets/draggable_widgets/ability/custom_circle_widget.dart b/lib/widgets/draggable_widgets/ability/custom_circle_widget.dart index 242f1d78f..ceb6e98b9 100644 --- a/lib/widgets/draggable_widgets/ability/custom_circle_widget.dart +++ b/lib/widgets/draggable_widgets/ability/custom_circle_widget.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_range_fill.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class CustomCircleWidget extends ConsumerWidget { @@ -48,27 +49,32 @@ class CustomCircleWidget extends ConsumerWidget { final scaledSize = coordinateSystem.scale(size); final scaledInnerRangeSize = coordinateSystem.scale(innerRangeSize ?? 0); final resolvedVisualState = visualState ?? const AbilityVisualState(); + final rangeOpacity = AbilityRangeOpacity.of(context); return Stack( children: [ _buildRangeFill( scaledSize, showRangeFill: resolvedVisualState.showRangeFill && !hasInnerRange, + rangeOpacity: rangeOpacity, ), _buildRangeOutline( coordinateSystem, scaledSize, showRangeOutline: resolvedVisualState.showRangeOutline, + rangeOpacity: rangeOpacity, ), if (hasInnerRange) ...[ _buildInnerRangeFill( scaledInnerRangeSize, showInnerFill: resolvedVisualState.showInnerFill, + rangeOpacity: rangeOpacity, ), _buildInnerRangeOutline( coordinateSystem, scaledInnerRangeSize, showInnerOutline: resolvedVisualState.showInnerOutline, + rangeOpacity: rangeOpacity, ), ], if (hasCenterDot) _buildCenterIcon(), @@ -79,6 +85,7 @@ class CustomCircleWidget extends ConsumerWidget { Widget _buildRangeFill( double scaledSize, { required bool showRangeFill, + required double rangeOpacity, }) { final fillColor = resolveAbilityRangeFillColor( rangeOutlineColor: rangeOutlineColor, @@ -87,7 +94,7 @@ class CustomCircleWidget extends ConsumerWidget { ); return Opacity( key: const ValueKey('circle-range-fill-layer'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, child: IgnorePointer( child: Container( width: scaledSize, @@ -105,10 +112,11 @@ class CustomCircleWidget extends ConsumerWidget { CoordinateSystem coordinateSystem, double scaledSize, { required bool showRangeOutline, + required double rangeOpacity, }) { return Opacity( key: const ValueKey('circle-range-outline-layer'), - opacity: showRangeOutline ? 1 : 0, + opacity: showRangeOutline ? rangeOpacity : 0, child: IgnorePointer( child: Container( width: scaledSize, @@ -119,7 +127,8 @@ class CustomCircleWidget extends ConsumerWidget { color: hasInnerRange ? rangeOutlineColor.withAlpha(100) : rangeOutlineColor, - width: coordinateSystem.scale(hasInnerRange || hasCenterDot ? 2 : 5), + width: + coordinateSystem.scale(hasInnerRange || hasCenterDot ? 2 : 5), ), ), ), @@ -130,11 +139,12 @@ class CustomCircleWidget extends ConsumerWidget { Widget _buildInnerRangeFill( double scaledInnerRangeSize, { required bool showInnerFill, + required double rangeOpacity, }) { return Positioned.fill( child: Opacity( key: const ValueKey('circle-inner-fill-layer'), - opacity: showInnerFill ? 1 : 0, + opacity: showInnerFill ? rangeOpacity : 0, child: IgnorePointer( child: Align( alignment: Alignment.center, @@ -156,11 +166,12 @@ class CustomCircleWidget extends ConsumerWidget { CoordinateSystem coordinateSystem, double scaledInnerRangeSize, { required bool showInnerOutline, + required double rangeOpacity, }) { return Positioned.fill( child: Opacity( key: const ValueKey('circle-inner-outline-layer'), - opacity: showInnerOutline ? 1 : 0, + opacity: showInnerOutline ? rangeOpacity : 0, child: IgnorePointer( child: Align( alignment: Alignment.center, diff --git a/lib/widgets/draggable_widgets/ability/custom_square_widget.dart b/lib/widgets/draggable_widgets/ability/custom_square_widget.dart index 13682f9a5..91ee2f4c1 100644 --- a/lib/widgets/draggable_widgets/ability/custom_square_widget.dart +++ b/lib/widgets/draggable_widgets/ability/custom_square_widget.dart @@ -6,6 +6,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/custom_border_container.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class CustomSquareWidget extends ConsumerWidget { @@ -49,6 +50,7 @@ class CustomSquareWidget extends ConsumerWidget { final List? contextMenuItems; @override Widget build(BuildContext context, WidgetRef ref) { + final rangeOpacity = AbilityRangeOpacity.of(context); final coordinateSystem = CoordinateSystem.instance; final double scaledWidth; @@ -102,7 +104,7 @@ class CustomSquareWidget extends ConsumerWidget { left: 0, child: Opacity( key: const ValueKey('square-range-body'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, child: IgnorePointer( child: CustomBorderContainer( color: color, @@ -123,13 +125,13 @@ class CustomSquareWidget extends ConsumerWidget { child: supportsInactiveState ? AnimatedOpacity( key: const ValueKey('square-range-body-transition'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, duration: abilityStateTransitionDuration, child: wallBody, ) : Opacity( key: const ValueKey('square-range-body'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, child: wallBody, ), ), @@ -139,7 +141,7 @@ class CustomSquareWidget extends ConsumerWidget { left: (scaledWidth - inactiveTraceWidth) / 2, child: AnimatedOpacity( key: const ValueKey('inactive-wall-trace-transition'), - opacity: showRangeFill ? 0 : 1, + opacity: showRangeFill ? 0 : rangeOpacity, duration: abilityStateTransitionDuration, child: IgnorePointer( child: SizedBox( diff --git a/lib/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart b/lib/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart index 3bbf3df3a..d0a52d5ef 100644 --- a/lib/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart +++ b/lib/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double deadlockBarrierMeshMinArmLengthMeters = 1.0; @@ -126,6 +127,7 @@ class DeadlockBarrierMeshWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final rangeOpacity = AbilityRangeOpacity.of(context); final coordinateSystem = CoordinateSystem.instance; final abilitySize = ref.watch(strategySettingsProvider).abilitySize; final normalizedArmLengths = @@ -147,7 +149,7 @@ class DeadlockBarrierMeshWidget extends ConsumerWidget { children: [ Opacity( key: const ValueKey('deadlock-mesh-layer'), - opacity: showMesh ? 1 : 0, + opacity: showMesh ? rangeOpacity : 0, child: Stack( clipBehavior: Clip.none, children: [ diff --git a/lib/widgets/draggable_widgets/ability/resizable_square_widget.dart b/lib/widgets/draggable_widgets/ability/resizable_square_widget.dart index 5a070c7da..e7b0109d9 100644 --- a/lib/widgets/draggable_widgets/ability/resizable_square_widget.dart +++ b/lib/widgets/draggable_widgets/ability/resizable_square_widget.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/custom_border_container.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class ResizableSquareWidget extends ConsumerWidget { @@ -51,6 +52,7 @@ class ResizableSquareWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final rangeOpacity = AbilityRangeOpacity.of(context); final coordinateSystem = CoordinateSystem.instance; final abilitySize = ref.watch(strategySettingsProvider).abilitySize; final double scaledWidth; @@ -99,7 +101,7 @@ class ResizableSquareWidget extends ConsumerWidget { left: isWall ? ((scaledWidth - width) / 2) : 0, child: Opacity( key: const ValueKey('square-range-body'), - opacity: showRangeFill ? 1 : 0, + opacity: showRangeFill ? rangeOpacity : 0, child: IgnorePointer( child: CustomBorderContainer( height: scaledLength, diff --git a/lib/widgets/draggable_widgets/ability/sector_circle_widget.dart b/lib/widgets/draggable_widgets/ability/sector_circle_widget.dart index 954cd96d4..350e54fea 100644 --- a/lib/widgets/draggable_widgets/ability/sector_circle_widget.dart +++ b/lib/widgets/draggable_widgets/ability/sector_circle_widget.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_range_fill.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class SectorCircleWidget extends ConsumerWidget { @@ -57,6 +58,7 @@ class SectorCircleWidget extends ConsumerWidget { final scaledInnerRangeSize = coordinateSystem.scale(innerRangeSize ?? 0); final scaledTopInset = coordinateSystem.scale(handleTopInsetVirtual); final resolvedVisualState = visualState ?? const AbilityVisualState(); + final rangeOpacity = AbilityRangeOpacity.of(context); final style = _SectorCircleStyle.fromValues( coordinateSystem: coordinateSystem, rangeOutlineColor: rangeOutlineColor, @@ -83,13 +85,16 @@ class SectorCircleWidget extends ConsumerWidget { children: [ Positioned.fill( child: IgnorePointer( - child: CustomPaint( - key: const ValueKey('sector-range-layer'), - painter: SectorCirclePainter( - sweepAngleDegrees: sweepAngleDegrees, - fillColor: style.fillColor, - strokeColor: style.strokeColor, - strokeWidth: style.strokeWidth, + child: Opacity( + opacity: rangeOpacity, + child: CustomPaint( + key: const ValueKey('sector-range-layer'), + painter: SectorCirclePainter( + sweepAngleDegrees: sweepAngleDegrees, + fillColor: style.fillColor, + strokeColor: style.strokeColor, + strokeWidth: style.strokeWidth, + ), ), ), ), @@ -98,11 +103,13 @@ class SectorCircleWidget extends ConsumerWidget { _buildInnerRangeFill( scaledInnerRangeSize, resolvedVisualState.showInnerFill, + rangeOpacity, ), _buildInnerRangeOutline( coordinateSystem, scaledInnerRangeSize, resolvedVisualState.showInnerOutline, + rangeOpacity, ), ], if (hasCenterDot) _buildCenterIcon(), @@ -118,6 +125,7 @@ class SectorCircleWidget extends ConsumerWidget { Widget _buildInnerRangeFill( double scaledInnerRangeSize, bool showInnerFill, + double rangeOpacity, ) { assert( innerRangeColor != null, @@ -127,7 +135,7 @@ class SectorCircleWidget extends ConsumerWidget { return Positioned.fill( child: Opacity( key: const ValueKey('sector-inner-fill-layer'), - opacity: showInnerFill ? 1 : 0, + opacity: showInnerFill ? rangeOpacity : 0, child: IgnorePointer( child: Align( alignment: Alignment.center, @@ -149,6 +157,7 @@ class SectorCircleWidget extends ConsumerWidget { CoordinateSystem coordinateSystem, double scaledInnerRangeSize, bool showInnerOutline, + double rangeOpacity, ) { assert( innerRangeColor != null, @@ -158,7 +167,7 @@ class SectorCircleWidget extends ConsumerWidget { return Positioned.fill( child: Opacity( key: const ValueKey('sector-inner-outline-layer'), - opacity: showInnerOutline ? 1 : 0, + opacity: showInnerOutline ? rangeOpacity : 0, child: IgnorePointer( child: Align( alignment: Alignment.center, @@ -313,7 +322,8 @@ class _SectorCircleStyle { return _SectorCircleStyle( fillColor: showRangeFill ? baseStyle.fillColor : null, - strokeColor: showRangeOutline ? baseStyle.strokeColor : Colors.transparent, + strokeColor: + showRangeOutline ? baseStyle.strokeColor : Colors.transparent, strokeWidth: showRangeOutline ? baseStyle.strokeWidth : 0, ); } diff --git a/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart b/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart index 3882e8a08..f17d46eec 100644 --- a/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; import 'package:icarus/widgets/mouse_watch.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class SimpleImageAbilityWidget extends ConsumerWidget { @@ -33,6 +34,7 @@ class SimpleImageAbilityWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final rangeOpacity = AbilityRangeOpacity.of(context); final coordinateSystem = CoordinateSystem.instance; final deleteTarget = landingId != null ? HoveredDeleteTarget.lineup(id: landingId!, ownerToken: Object()) @@ -50,14 +52,14 @@ class SimpleImageAbilityWidget extends ConsumerWidget { children: [ AnimatedOpacity( key: const ValueKey('image-ability-active-layer'), - opacity: isActive ? 1 : 0, + opacity: isActive ? rangeOpacity : 0, duration: abilityStateTransitionDuration, child: Image.asset(imagePath), ), if (supportsInactiveState) AnimatedOpacity( key: const ValueKey('image-ability-inactive-layer'), - opacity: isActive ? 0 : 1, + opacity: isActive ? 0 : rangeOpacity, duration: abilityStateTransitionDuration, child: InactiveCircleAbilityTrace( color: inactiveTraceColor!, diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 9128a4556..abbfc1a1f 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -10,6 +10,7 @@ import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/widgets/canonical_map_artwork.dart'; import 'package:icarus/widgets/dot_painter.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:icarus/widgets/map_svg_color_mapper.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; import 'package:icarus/widgets/page_transition_overlay.dart'; @@ -37,13 +38,13 @@ class ReplayCanvas extends ConsumerStatefulWidget { } /// How strongly ranges and areas players stand in are drawn, so the agents -/// and cones beneath stay readable. +/// and cones beneath stay readable. Their icons stay solid. const _rangeOpacity = 0.3; -/// [view] drawn at [opacity]. A placed widget comes back positioned on the -/// canvas, so the fade goes inside its position. -Widget _faded(Widget view, double opacity) { - if (view is! Positioned) return Opacity(opacity: opacity, child: view); +/// [view] with [wrap] around it. A placed widget comes back positioned on +/// the canvas, so the wrapper goes inside its position. +Widget _inside(Widget view, Widget Function(Widget) wrap) { + if (view is! Positioned) return wrap(view); return Positioned( key: view.key, left: view.left, @@ -52,10 +53,18 @@ Widget _faded(Widget view, double opacity) { bottom: view.bottom, width: view.width, height: view.height, - child: Opacity(opacity: opacity, child: view.child), + child: wrap(view.child), ); } +/// [view] drawn at [opacity]. +Widget _faded(Widget view, double opacity) => + _inside(view, (child) => Opacity(opacity: opacity, child: child)); + +/// [view] with its range drawn faint and its icon solid. +Widget _faintRange(Widget view) => _inside( + view, (child) => AbilityRangeOpacity(opacity: _rangeOpacity, child: child)); + class _ReplayCanvasState extends ConsumerState { final _controller = TransformationController(); Size? _lastViewport; @@ -187,9 +196,10 @@ class _ReplayCanvasState extends ConsumerState { ), for (final exit in exits) _faded( - view(exit.ability), - (1 - exit.progress) * - (exit.dimmed ? _rangeOpacity : 1), + exit.dimmed + ? _faintRange(view(exit.ability)) + : view(exit.ability), + 1 - exit.progress, ), Positioned.fill( child: IgnorePointer( @@ -296,7 +306,7 @@ class _UtilityLayerState extends State<_UtilityLayer> { children: [ for (final placed in widget.placed) widget.dimmed.contains(placed.id) - ? _faded(widget.view(placed), _rangeOpacity) + ? _faintRange(widget.view(placed)) : widget.view(placed), ], ), diff --git a/test/ability_visibility_widgets_test.dart b/test/ability_visibility_widgets_test.dart index 98d603c84..67a1f6eac 100644 --- a/test/ability_visibility_widgets_test.dart +++ b/test/ability_visibility_widgets_test.dart @@ -14,6 +14,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_range_opacity.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/custom_square_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; @@ -246,6 +247,55 @@ void main() { ); }); + testWidgets('a faint range fades the range and never the icon', + (tester) async { + final container = ProviderContainer(); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + + final circle = CircleAbility( + iconPath: 'assets/agents/Cypher/1.webp', + size: 10, + rangeOutlineColor: Colors.white, + hasCenterDot: true, + ); + await tester.pumpWidget( + _buildHarness( + container: container, + child: Center( + child: AbilityRangeOpacity( + opacity: 0.3, + child: circle.createWidget( + id: 'faint-circle', + isAlly: true, + mapScale: 1, + watchMouse: false, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final layer in [ + 'circle-range-fill-layer', + 'circle-range-outline-layer', + ]) { + expect(tester.widget(find.byKey(ValueKey(layer))).opacity, + 0.3); + } + expect( + find.ancestor( + of: find.byType(AbilityWidget), + matching: find.byWidgetPredicate( + (widget) => widget is Opacity && widget.opacity < 1), + ), + findsNothing, + ); + }); + testWidgets('circle visibility toggles preserve icon and target layers', (tester) async { final container = ProviderContainer(); From 50a3c36bcd5aa7fc4c08cea70d8b7f936b42e03a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 16:45:14 -0400 Subject: [PATCH 15/18] Show a back arrow in an editor opened from a replay capture Opening a captured strategy pushes the editor over the replay, so its leading button returns to the replay, yet it showed the house. StrategyView takes an optional backTooltip: when set, the button is a back chevron with that tooltip ("Back to replay"); opened from the library it is still the house. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/strategy_view.dart | 18 ++++++++++++++++-- lib/widgets/replay/replay_match_card.dart | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 87f0c2718..f99352f88 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -36,6 +36,7 @@ class StrategyView extends ConsumerStatefulWidget { this.initialMapValue, this.initialIsAttack = true, this.initialPageId, + this.backTooltip, }); final String? initialStrategyId; @@ -46,12 +47,18 @@ class StrategyView extends ConsumerStatefulWidget { /// The page to open on; the first when null. final String? initialPageId; + /// Set when the editor was opened from somewhere other than the library + /// (a replay's Capture): leaving goes back there, so the leading button is + /// a back arrow with this tooltip instead of the house. + final String? backTooltip; + static PageRoute route({ String? initialStrategyId, String? initialStrategyName, MapValue? initialMapValue, bool initialIsAttack = true, String? initialPageId, + String? backTooltip, }) { return PageRouteBuilder( settings: const RouteSettings(name: Routes.strategyView), @@ -63,6 +70,7 @@ class StrategyView extends ConsumerStatefulWidget { initialMapValue: initialMapValue, initialIsAttack: initialIsAttack, initialPageId: initialPageId, + backTooltip: backTooltip, ), transitionsBuilder: (context, animation, _, child) { return FadeTransition( @@ -211,7 +219,8 @@ class _StrategyViewState extends ConsumerState children: [ const SizedBox(width: 6), ShadTooltip( - builder: (context) => const Text('Library'), + builder: (context) => + Text(widget.backTooltip ?? 'Library'), child: ShadIconButton.ghost( width: 28, height: 28, @@ -220,7 +229,12 @@ class _StrategyViewState extends ConsumerState hoverForegroundColor: Settings.tacticalVioletTheme.foreground, onPressed: _leaveToLibrary, - icon: const Icon(LucideIcons.house300, size: 18), + icon: Icon( + widget.backTooltip == null + ? LucideIcons.house300 + : LucideIcons.chevronLeft300, + size: 18, + ), ), ), const IcarusWordmark(), diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 0b262a222..76ef7abfe 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -89,6 +89,7 @@ class _ReplayMatchCardState extends ConsumerState { initialMapValue: strategy.mapData, initialIsAttack: page.isAttack, initialPageId: page.id, + backTooltip: 'Back to replay', ), ); if (!mounted) return; From 1f8a8105a72a10114259d4a63ddceaadc5109414 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 17:41:18 -0400 Subject: [PATCH 16/18] Pin Split's crane spot to the floor default #238 gives it Under the 36.5 m crane arm the default used to be the arm, so a replay saved the floor's height; with no surface 10 m or more above its ground as a default (#238), the floor is the default and nothing is saved. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/replay_map_projection_test.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/replay_map_projection_test.dart b/test/replay_map_projection_test.dart index ac31d0386..213c5498c 100644 --- a/test/replay_map_projection_test.dart +++ b/test/replay_map_projection_test.dart @@ -264,14 +264,13 @@ void main() { expect(elevation(MapValue.sunset, -4621.59, 719.23, 300.3), isNull); }); - test('Split: under an automatic roof support, the floor', () { - // c8989335 at 1913.0 s: the default stands the eye on a support 37 m - // up; the player's feet are at 6.3 m on 6.5 m ground. + test('Split: under the crane arm the default is already the floor', () { + // c8989335 at 1913.0 s: the player's feet are at 6.3 m on 6.5 m + // ground under the 36.5 m crane arm. No surface 10 m or more above + // its ground is a default (#238), so the floor needs no saved height. final (x, y, z) = (2548.0, -4509.0, 730.0); - expect(surfaceUnder(MapValue.split, x, y, null), greaterThan(37)); - final saved = elevation(MapValue.split, x, y, z); - expect(saved, closeTo(825, .5)); - expect(surfaceUnder(MapValue.split, x, y, saved), closeTo(6.5, .01)); + expect(surfaceUnder(MapValue.split, x, y, null), closeTo(6.5, .01)); + expect(elevation(MapValue.split, x, y, z), isNull); }); test('Lotus: under a 9.5 m platform, the floor beneath it', () { @@ -311,7 +310,6 @@ void main() { test('the defense side resolves the same level', () { for (final (map, x, y, z) in const [ - (MapValue.split, 2548.0, -4509.0, 730.0), (MapValue.lotus, 8347.0, 4240.0, 439.0), (MapValue.sunset, 2619.0, -3652.0, 800.0), (MapValue.split, -198.90, -5402.98, 600.0), From 58f9f0073efcaad47a2df0e4cc1456062c327d69 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 18:05:41 -0400 Subject: [PATCH 17/18] Start a replay capture's strategy in the local library Since the cloud workspace landed, createNewStrategy follows the selected workspace: signed in, Capture made a blank strategy on the server (and opened it) while writing the captured page into the local library, where that strategy does not exist. createNewStrategy takes local: true, which creates in the local library's open folder whatever workspace is selected; Capture passes it, since it writes pages there from files on this computer. Local strategies already sit beside cloud ones in My Library. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/providers/strategy_provider.dart | 13 ++++++++--- lib/widgets/replay/replay_match_card.dart | 2 +- test/widgets/web_beta_library_test.dart | 27 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index bea9989a9..0fa31be69 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1167,13 +1167,16 @@ class StrategyProvider extends Notifier { /// Creates an empty strategy on [map] and returns it. Without [name] it /// is auto-named after the map ("Haven", then "Haven 2", ...). In the cloud /// workspace the strategy is created on the server and opened; nothing is - /// written to the local library. + /// written to the local library. With [local] it goes to the local library + /// (its open folder) whichever workspace is selected: a replay's Capture + /// writes its pages there, from files on this computer. Future createNewStrategy({ required MapValue map, String? name, + bool local = false, }) async { final box = Hive.box(HiveBoxNames.strategiesBox); - final isCloud = _selectedWorkspaceIsCloud(); + final isCloud = !local && _selectedWorkspaceIsCloud(); if (!isCloud) _requireLocalLibrary('create'); final existingNames = isCloud ? (ref.read(cloudStrategiesProvider).valueOrNull ?? const []) @@ -1216,7 +1219,11 @@ class StrategyProvider extends Notifier { // ignore: deprecated_member_use_from_same_package strategySettings: defaultSettings, - folderID: ref.read(folderProvider), + folderID: local + ? ref + .read(folderProvider.notifier) + .currentFolderIdForWorkspace(LibraryWorkspace.local) + : ref.read(folderProvider), themeProfileId: defaultThemeProfileId, ); diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 76ef7abfe..17bfea321 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -37,7 +37,7 @@ class _ReplayMatchCardState extends ConsumerState { late final ReplayCapture _capture = ReplayCapture( createStrategy: (map, name) => ref .read(strategyProvider.notifier) - .createNewStrategy(map: map, name: name), + .createNewStrategy(map: map, name: name, local: true), map: widget.map, name: '${replayMapName(widget.map)} replay · ' '${_dateLabel(widget.playback.document.match.recordedAt)}', diff --git a/test/widgets/web_beta_library_test.dart b/test/widgets/web_beta_library_test.dart index cd90feb82..44ad39f75 100644 --- a/test/widgets/web_beta_library_test.dart +++ b/test/widgets/web_beta_library_test.dart @@ -18,6 +18,7 @@ import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -349,6 +350,32 @@ void main() { expect(find.text('Local Plan'), findsOneWidget); expect(find.byKey(const ValueKey('web-beta-tag')), findsNothing); }); + + testWidgets('signed in, a replay capture still starts a local strategy', + (tester) async { + final library = await _pumpLibrary( + tester, + policy: PlatformPolicy.desktop, + auth: _ready, + ); + expect(library.workspace, LibraryWorkspace.cloud); + final container = ProviderScope.containerOf( + tester.element(find.byType(FolderNavigator)), + ); + + // Capture writes its pages into the local library, so the strategy + // it starts must be there too, not a blank one on the server. + final strategy = (await tester.runAsync(() => container + .read(strategyProvider.notifier) + .createNewStrategy( + map: MapValue.ascent, name: 'Ascent replay', local: true)))!; + + expect(library.repository.calls, isEmpty); + expect( + Hive.box(HiveBoxNames.strategiesBox).get(strategy.id), + isNotNull, + ); + }); }); group('PlatformPolicy', () { From 977472c6a0852053760ce7d31e53c6114c964f00 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 18:05:41 -0400 Subject: [PATCH 18/18] Set the replay window title in the body type role DESIGN.md has five type roles; the title used 13px/500, which is none of them. Body (14px/400) is also the size of the editor's title in the same place. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/replay_view.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/replay_view.dart b/lib/replay_view.dart index 98a42bf39..35b978289 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -345,9 +345,10 @@ class _ReplayStrip extends StatelessWidget { probe.recordedAt ?? DateTime.now(), ), ].join(' · '), + // DESIGN.md body role, the size of the editor's title. style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, + fontSize: 14, + fontWeight: FontWeight.w400, color: theme.foreground, ), ),