diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ebcfd4..b4cd8bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,55 @@ env: PUB_CACHE: ${{ github.workspace }}/.pub-cache 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 + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt + + - 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', 'native/replay/rust-toolchain.toml') }} + 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 + cargo test --locked --features dev-vectors + # Decides which lanes a run needs. Every lane runs for manual runs, PRs into # main, PRs that change CI itself, and PRs whose files can't be listed. # Pushes run every lane but the desktop builds: the release runs the upgrade diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 0873623d..70857894 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -116,6 +116,13 @@ jobs: shell: pwsh run: fvm install + # windows/CMakeLists.txt builds the replay decoder (native/replay) with + # cargo, on the version in native/replay/rust-toolchain.toml. + - name: Install Rust + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt + - name: Build Windows Release Binaries shell: pwsh env: diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 5657f3e5..e30285f4 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -59,6 +59,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, on the version in native/replay/rust-toolchain.toml. + - name: Install Rust + uses: dtolnay/rust-toolchain@1.96.1 + with: + components: clippy, rustfmt + - name: Build Store Package shell: pwsh env: diff --git a/CONTEXT.md b/CONTEXT.md index d76e2e85..e73eb6a1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,6 +57,23 @@ 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 + **Op**: One queued change to cloud data. An op lands when the server accepts it. Its op ID names that exact change. Changing the intended work creates a new op diff --git a/docs/adr/0006-replay-decoder.md b/docs/adr/0006-replay-decoder.md new file mode 100644 index 00000000..890204ea --- /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 00000000..bc085da1 --- /dev/null +++ b/docs/replay-format.md @@ -0,0 +1,284 @@ +# Decoded replay format + +`native/replay` turns a Valorant `.vrf` into one decoded replay: a byte +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. + +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.2.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 + "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 + "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], …] // see "Vitals" + }, + "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, // 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, // 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] + } + ], + "movement": { + "": { "offset": 0, "count": 18213 } + }, + "quality": { + "transformVerified": true, + "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" + } +} +``` + +## 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`, 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). + +## 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: 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 + +`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.2.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. +`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/config/platform_policy.dart b/lib/config/platform_policy.dart index 3adcab4d..171db25c 100644 --- a/lib/config/platform_policy.dart +++ b/lib/config/platform_policy.dart @@ -10,7 +10,8 @@ enum PlatformFeature { videoExport('Video export'), fileDrop('Drag and drop'), addImages('Adding images'), - addLineups('Adding lineups'); + addLineups('Adding lineups'), + replays('Replays'); const PlatformFeature(this.label); @@ -48,6 +49,8 @@ class PlatformPolicy { PlatformFeature.exportFiles, PlatformFeature.importFiles, PlatformFeature.fileDrop, + // Decoding a replay needs the native decoder and the file on disk. + PlatformFeature.replays, }, // Empty for now; the Beta dialog hides its "Coming" list when it is. comingToWebBeta: {}, diff --git a/lib/const/coordinate_system.dart b/lib/const/coordinate_system.dart index f8ed5c7e..d2e4e31e 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/const/settings.dart b/lib/const/settings.dart index 461f3eb0..17722dbe 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -107,6 +107,11 @@ class Settings { static const Color defenderColor = Color(0xFF6BA6C9); static Color get mixedStrategyColor => tacticalVioletTheme.mutedForeground; + // 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/library_navigation_provider.dart b/lib/providers/library_navigation_provider.dart index fca090df..35261098 100644 --- a/lib/providers/library_navigation_provider.dart +++ b/lib/providers/library_navigation_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; final libraryNavigationProvider = Provider((ref) { return LibraryNavigation(ref); @@ -15,6 +16,7 @@ class LibraryNavigation { /// Opens My Library. The cloud is the active store when it is reachable so /// new strategies and folders land online; otherwise the local library is. void showLibrary() { + _ref.read(replaysTabOpenProvider.notifier).state = false; final cloudAvailable = _ref.read(isCloudWorkspaceAvailableProvider); _ref.read(libraryWorkspaceProvider.notifier).select( cloudAvailable ? LibraryWorkspace.cloud : LibraryWorkspace.local, @@ -33,6 +35,7 @@ class LibraryNavigation { if (!_ref.read(isCloudWorkspaceAvailableProvider)) { return false; } + _ref.read(replaysTabOpenProvider.notifier).state = false; _ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); _ref .read(cloudLibrarySectionProvider.notifier) @@ -44,9 +47,17 @@ class LibraryNavigation { } void showCommunity() { + _ref.read(replaysTabOpenProvider.notifier).state = false; _ref .read(libraryWorkspaceProvider.notifier) .select(LibraryWorkspace.community); _ref.read(folderProvider.notifier).updateID(null); } + + /// Opens Replays, listing the recordings again so a match Valorant just + /// downloaded shows up. + void showReplays() { + _ref.read(replaysTabOpenProvider.notifier).state = true; + _ref.read(replayLibraryProvider.notifier).refresh(); + } } diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart index 68084748..ce643a13 100644 --- a/lib/providers/library_workspace_provider.dart +++ b/lib/providers/library_workspace_provider.dart @@ -90,17 +90,24 @@ class CloudLibrarySectionNotifier extends Notifier { } } -/// The three destinations in the library's title strip. `library` is the -/// user's own work from every store; `shared` is what teammates gave them; -/// `community` is the public space. The active store ([libraryWorkspaceProvider]) +/// The destinations in the library's title strip. `library` is the user's +/// own work from every store; `shared` is what teammates gave them; +/// `community` is the public space; `replays` is the Valorant match +/// recordings on this computer. The active store ([libraryWorkspaceProvider]) /// stays an implementation detail behind the first tab. enum LibraryTab { library, shared, community, + replays, } +/// Whether the Replays tab is open. Replays are files on this computer, not +/// a store, so the tab sits apart from the workspace the others choose. +final replaysTabOpenProvider = StateProvider((ref) => false); + final libraryTabProvider = Provider((ref) { + if (ref.watch(replaysTabOpenProvider)) return LibraryTab.replays; final workspace = ref.watch(libraryWorkspaceProvider); if (workspace == LibraryWorkspace.community) { return LibraryTab.community; diff --git a/lib/providers/replay_library_provider.dart b/lib/providers/replay_library_provider.dart new file mode 100644 index 00000000..1810c0f7 --- /dev/null +++ b/lib/providers/replay_library_provider.dart @@ -0,0 +1,74 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_files.dart'; + +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> { + /// 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(); + 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); + } + + /// One unreadable file is listed as such; it never hides the others. + Future<({ReplayProbe? probe, ReplayDecodeException? error})> _probe( + ReplayFile file, + ) async { + try { + return (probe: await probeReplay(file.path), error: null); + } on ReplayDecodeException catch (error) { + return (probe: null, error: error); + } catch (error) { + return ( + probe: null, + error: const ReplayDecodeException( + 'corrupt', + "This file couldn't be read.", + ), + ); + } + } + + 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 _listing(file); + } +} diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 14afcc7a..0d315371 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -300,16 +300,23 @@ class StrategyPageSessionNotifier extends Notifier { String? get activePageId => state.activePageId; + /// Opens [strategyId]'s pages on [preferredPageId] when it has one, else + /// on the page already active, else (with [selectFirstPageIfNeeded]) the + /// first. Future initializeForStrategy({ required String strategyId, required StrategySource source, required bool selectFirstPageIfNeeded, + String? preferredPageId, }) async { _pageSessionGeneration++; final pageSource = _resolvePageSource(strategyId, source); final pageIds = await pageSource.listPageIds(); - final initialPageId = - pageIds.contains(state.activePageId) ? state.activePageId : null; + final initialPageId = pageIds.contains(preferredPageId) + ? preferredPageId + : pageIds.contains(state.activePageId) + ? state.activePageId + : null; final selected = initialPageId ?? (selectFirstPageIfNeeded && pageIds.isNotEmpty ? pageIds.first : null); diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 6b2b4653..0fa31be6 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1103,7 +1103,8 @@ class StrategyProvider extends Notifier { return true; } - Future loadFromHive(String id) async { + /// Opens strategy [id] on its first page, or on [pageId] when given. + Future loadFromHive(String id, {String? pageId}) async { _requireLocalLibrary('open'); cancelPendingSave(); final newStrat = Hive.box(HiveBoxNames.strategiesBox) @@ -1158,6 +1159,7 @@ class StrategyProvider extends Notifier { strategyId: migratedStrategy.id, source: StrategySource.local, selectFirstPageIfNeeded: true, + preferredPageId: pageId, ); ref.read(strategySaveStateProvider.notifier).markPersisted(); } @@ -1165,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 []) @@ -1214,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/replay/replay_ability_catalog.dart b/lib/replay/replay_ability_catalog.dart new file mode 100644 index 00000000..9e0f08bf --- /dev/null +++ b/lib/replay/replay_ability_catalog.dart @@ -0,0 +1,585 @@ +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: a cosmetic, game bookkeeping, or a +/// piece of an ability another actor already draws. +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, + + /// 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 { + const ReplayAbilityEntry._( + this.agent, + this.abilityIndex, + this.kind, + this._placement, { + this.blocks, + }); + + 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; + + /// 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 + /// 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.virtualToWorld( + 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. +/// +/// 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({this.yawOffset = 0}); + + /// Degrees from the utility's yaw to the way the shape runs. + final double yawOffset; + + @override + _Geometry resolve({ + required ReplayUtility utility, + required ReplayMapProjection projection, + required int timeMs, + required Ability shape, + required double mapScale, + }) { + final points = utility.points; + // 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 = + 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! + yawOffset); + } else { + rotation = 0; + } + + var nearEdge = 0.0; + var length = 0.0; + if (shape is SquareAbility) { + nearEdge = CoordinateSystem.virtualLengthInWorld( + shape.distanceBetweenAOE * mapScale); + if (shape is ResizableSquareAbility && end != null) { + length = (end - start).distance / + CoordinateSystem.virtualLengthInWorld(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.virtualLengthInWorld( + 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 _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) => + 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': _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), + '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. + '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), + '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 + // 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), + 'Zone_Gumshoe_4_Cage': _smoke(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. + // 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._( + AgentType.deadlock, 2, ReplayUtilityKind.barrierMesh, _BarrierMesh()), + 'GameObject_FishingHook_CageSphere': _marker(AgentType.deadlock, 3), + + // 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), + '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), + '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), + + // 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.projectile, _point, + blocks: ReplayVisionBlock.line), + 'GameObject_Mage_E_WorldSmoke': _smoke(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': _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), + '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), + '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': _smoke(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': _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 flare + // replicates its flight as its own projectile, left or right. + 'GameObject_Phoenix_Q_FlameWallManager_Production': + 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). + '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), + '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), + '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), + '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), + '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': _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 + // 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), + '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), + + // 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 00000000..5ce8ad01 --- /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) => + agentId == null ? null : replayAgentsById[agentId.toLowerCase()]; diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart new file mode 100644 index 00000000..dee582db --- /dev/null +++ b/lib/replay/replay_capture.dart @@ -0,0 +1,112 @@ +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'; +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.createStrategy, + required this.map, + required this.name, + }); + + /// 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. + final String name; + + String? _strategyId; + + /// 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; + 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. + /// 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 createStrategy(map, 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); + _lastPageId = page.id; + return updated; + } + + static void _checkFinite(ReplayFrame frame) { + for (final widget in frame.widgets) { + 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_cone_cuts.dart b/lib/replay/replay_cone_cuts.dart new file mode 100644 index 00000000..6406d2af --- /dev/null +++ b/lib/replay/replay_cone_cuts.dart @@ -0,0 +1,157 @@ +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. +@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); + + /// 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 +/// 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; + + /// 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. + final VoidCallback onCut; + + 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; + + 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) { + aim = aim.placed; + if (_waiting.contains(subject) || + _latest[subject]?.aim == aim || + _failed[subject] == aim) { + return; + } + _waiting.add(subject); + worker.cut(_request(aim)).then((result) { + _waiting.remove(subject); + if (_disposed) return; + _latest[subject] = ReplayConeCut( + _nextId++, + aim, + result.origin, + result.cone, + ); + onCut(); + }, onError: (Object _) { + _waiting.remove(subject); + if (_disposed) return; + _failed[subject] = aim; + onCut(); + }); + } + + /// 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: 2 * math.pi, + elevationCm: aim.elevationCm, + ); + } + + 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 00000000..b9652a42 --- /dev/null +++ b/lib/replay/replay_cone_worker.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:icarus/view_cone/svg_floor_visibility.dart'; +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 the editor's: [SvgHeightVisibility.horizontalCone], and where +/// a map overlooks reviewed floors, [SvgHeightVisibility.withSightlineFloors]. +/// Both are plain geometry; paths are made only when a cone is painted. +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 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 == 4) { + worker._answer(message[0] as int, message[1], + floors: message[2] as List, + sector: message[3] as List); + } 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', + ); + try { + worker = ReplayConeWorker._(isolate, await ready.future, responses); + } catch (_) { + responses.close(); + isolate.kill(priority: Isolate.immediate); + rethrow; + } + 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, + {List floors = const [], List? sector}) { + 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], + floors: floors, + sector: sector, + ), + )); + } + + 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 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) { + 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.withSightlineFloors( + model.horizontalCone( + origin: standing, + directionRadians: request[4], + range: request[5], + apertureRadians: request[6], + supportId: support?.id, + ), + origin: standing, + directionRadians: request[4], + range: request[5], + apertureRadians: request[6], + ); + 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(cone.floors.isEmpty + ? [ticket, payload] + : [ticket, payload, cone.floors, cone.sector]); + } catch (error, stack) { + replies.send(['$error', '$stack', ticket]); + } + }); +} diff --git a/lib/replay/replay_decoder.dart b/lib/replay/replay_decoder.dart new file mode 100644 index 00000000..5ce0a7fc --- /dev/null +++ b/lib/replay/replay_decoder.dart @@ -0,0 +1,85 @@ +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: [ + // A player the header names no agent for is left out, not fatal. + for (final player in (json['players'] as List? ?? const [])) + if ((player as Map)['agentId'] case final String agentId) + agentId.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 00000000..1375a693 --- /dev/null +++ b/lib/replay/replay_decoder_io.dart @@ -0,0 +1,169 @@ +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; +} + +/// 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<_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, _DecodeC>( + 'icarus_replay_decode', + ), + 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 _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; + + /// 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) { + // 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(); + try { + return library.take( + library.decode( + nativePath, + Pointer<_Control>.fromAddress(controlAddress), + ), + ); + } finally { + malloc.free(nativePath); + } + }).whenComplete(() { + _done = true; + _library.controlFree(_control); + }); + } + + 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 : _library.controlProgress(_control) / 10000; + + @override + void cancel() { + if (!_done) _library.controlCancel(_control); + } +} diff --git a/lib/replay/replay_decoder_stub.dart b/lib/replay/replay_decoder_stub.dart new file mode 100644 index 00000000..c8244a96 --- /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 00000000..d014963b --- /dev/null +++ b/lib/replay/replay_document.dart @@ -0,0 +1,668 @@ +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`; ability casts +/// are in it too but nothing reads them yet, so they are not parsed. +class ReplayDocument { + ReplayDocument._({ + required this.match, + required this.players, + required this.rounds, + required this.kills, + required this.utility, + 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; + + /// 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 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 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'); + } + } + + 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)), + 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, 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']), + 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; + + /// 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; + 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']), + 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: json['position'] == null + ? null + : 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, + this.credits, + this.weapon, + this.armor, + }); + + final String subject; + + /// 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; + + /// 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?, + 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], 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 fromMs = spawnMs; + var from = position; + for (final point in path) { + 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 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, + 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'] == null ? null : _finite(json['yaw'] as num), + 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 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(), + ); +} + +/// [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); + + final double x; + 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), + _finite(json[2] as num), + ); +} + +class ReplayPathPoint { + const ReplayPathPoint(this.timeMs, this.position); + + 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 num).toInt(), + ReplayVector( + _finite(json[1] as num), + _finite(json[2] as num), + _finite(json[3] as num), + ), + ); +} + +/// 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.'); + } + 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) => + _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 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]. + 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 num).toInt(); + health[i] = _finite(row[1] as num); + armor[i] = _finite(row[2] as num); + } + return ReplayVitalsTrack._(times, health, armor); + } + + /// 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; + 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 ( + 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 new file mode 100644 index 00000000..b5a9e172 --- /dev/null +++ b/lib/replay/replay_files.dart @@ -0,0 +1,225 @@ +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. 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. 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}'; +} + +/// 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. A + /// folder that can't be read lists nothing; the other still does. + 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, + ); + } + } + 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 _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)) { + 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. 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))); + 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 source.copy(partial.path); + // 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(); + } + } + final stat = await destination.stat(); + return ReplayFile( + path: destination.path, + source: ReplaySource.icarus, + sizeBytes: stat.size, + modified: stat.modified, + ); + } + + /// 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, + '${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 = _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 new file mode 100644 index 00000000..0a0bb44f --- /dev/null +++ b/lib/replay/replay_frame.dart @@ -0,0 +1,515 @@ +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/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 +/// 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, + this.occluders = const [], + this.cones = const {}, + this.dimmed = const {}, + this.effects = const [], + }); + + 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; + + /// 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({ + 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; + + /// 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; + + late final Map> _deathsBySubject = () { + final deaths = >{}; + for (final kill in document.kills) { + (deaths[kill.victim] ??= []).add(kill); + } + 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 = () { + 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]: 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 roundStart = round?.startMs ?? 0; + 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; + } + final death = _lastDeathBefore(player.subject, timeMs); + if (death != null && death.timeMs >= decidedAt) alive = false; + return alive; + } + + /// [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]; + ReplayPose? pose; + if (track != null) { + if (alive) { + // A quiet stream means standing still, not vanishing. + pose = track.poseAt(poseTime) ?? + track.heldPose(poseTime, 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: vitalsCurrent ? vitals.health : null, + armor: vitalsCurrent ? vitals.armor : null, + ); + } + + ReplayKill? _lastDeathBefore(String subject, int timeMs) { + ReplayKill? last; + for (final death in _deathsBySubject[subject] ?? const []) { + if (death.timeMs > timeMs) break; + last = death; + } + 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; + 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]; + + /// 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, + 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); + final pose = state.pose; + if (pose == null) continue; + 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}'; + 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); + agents.add( + state.alive + ? PlacedViewConeAgent( + id: id, + type: type, + position: position, + presetType: UtilityType.viewCone180, + rotation: rotation, + length: coneLength, + visionElevation: visionElevation, + isAlly: isAlly, + weapon: weapon, + ) + : PlacedAgent( + id: id, + type: type, + position: position, + isAlly: isAlly, + state: AgentState.dead, + weapon: weapon, + ), + ); + } + + final abilities = []; + 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; + 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; + 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 = []; + final plant = round?.plant; + final plantedAt = plant?.position; + if (round != null && + plant != null && + plantedAt != null && + timeMs >= plant.timeMs && + timeMs < _spikeGoneMs(round)) { + final spike = PlacedUtility( + id: 'replay-spike-${round.index}', + type: UtilityType.spike, + position: Offset.zero, + ); + spike.position = projection.toWorld(plantedAt.x, plantedAt.y) - + CoordinateSystem.virtualToWorld( + 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, + occluders: occluders, + cones: cones, + dimmed: dimmed, + effects: effects, + ); + } + + /// 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 virtual = switch (entry.ability.abilityData) { + CircleAbility(:final size) || ImageAbility(:final size) => + 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_loader.dart b/lib/replay/replay_loader.dart new file mode 100644 index 00000000..dcbe8286 --- /dev/null +++ b/lib/replay/replay_loader.dart @@ -0,0 +1,66 @@ +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'; + +/// 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; + bool _cancelled = false; + + /// 0 to 1 while decoding. + double get progress => _job?.progress ?? 0; + + /// Stops the load wherever it is; [load] then fails as cancelled. + void cancel() { + _cancelled = true; + _job?.cancel(); + } + + 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 bytes = await job.result; + _throwIfCancelled(); + final document = await _parse(bytes); + _throwIfCancelled(); + try { + await files.writeCache(file, probe.decoderVersion, bytes); + } on Exception { + // A failed cache write only costs a decode next time. + } + 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 : await _parse(bytes); + } on Exception { + return null; + } + } +} diff --git a/lib/replay/replay_map_projection.dart b/lib/replay/replay_map_projection.dart new file mode 100644 index 00000000..de36e5e0 --- /dev/null +++ b/lib/replay/replay_map_projection.dart @@ -0,0 +1,241 @@ +import 'dart:math' as math; +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. +/// +/// 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; + + /// 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 + // 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), + }; +} + +/// 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); + + 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 00000000..6407d00a --- /dev/null +++ b/lib/replay/replay_playback.dart @@ -0,0 +1,154 @@ +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'; +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. +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; + + /// 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; + + 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); + + /// 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 && + cached.timeMs == _timeMs && + _framePerspective == _perspective) { + return cached; + } + _framePerspective = _perspective; + return _frame = frames.frameAt( + _timeMs, + perspective: _perspective, + cuts: _coneCuts, + ); + } + + ReplayFrame? _frame; + ReplayTeam? _framePerspective; + + /// 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(); + } + + /// 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; + /// 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; + _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); + _carryMs = 0; + 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 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(); + } + + /// 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 00000000..41f06123 --- /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 00000000..35b97828 --- /dev/null +++ b/lib/replay_view.dart @@ -0,0 +1,465 @@ +import 'dart:async'; +import 'dart:io'; + +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/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'; +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'; +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; + 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; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + _loader = ReplayLoader( + files: ref.read(replayFilesProvider), + 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), + (_) => setState(() {}), + ); + _open(); + } + + Future _open() async { + try { + final projection = ReplayMapProjection.forMapPath(widget.probe.mapPath); + if (projection == null) { + throw const ReplayDecodeException( + 'unsupportedMap', + "This replay's map isn't in Icarus yet.", + ); + } + 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(_syncMapSide); + if (_heightModel != null) playback.heightModel = _heightModel; + setState(() => _playback = playback); + unawaited(_startConeCuts()); + claimEditorState(); + _ticker.start(); + } on ReplayDecodeException catch (error) { + if (mounted && !error.isCancelled) setState(() => _error = error); + } on FormatException catch (error) { + _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(); + } + } + + /// 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; + _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) { + if (mounted) setState(() => _error = error); + } + + /// The team of whoever played this match on this machine, else Red. + Future _defaultPerspective(ReplayDocument document) async { + Set local; + try { + local = await ref.read(replayFilesProvider).localSubjects(); + } on FileSystemException { + local = const {}; + } + for (final player in document.players) { + final team = player.team; + if (team != null && local.contains(player.subject.toLowerCase())) { + return team; + } + } + return ReplayTeam.red; + } + + void _onTick(Duration elapsed) { + final delta = elapsed - _lastTick; + _lastTick = elapsed; + _playback?.advance(delta); + } + + /// 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) return; + final preferences = ref.read(appPreferencesProvider); + ref.read(strategySettingsProvider.notifier).fromHive( + StrategySettings( + agentSize: preferences.defaultAgentSizeForNewStrategies, + abilitySize: preferences.defaultAbilitySizeForNewStrategies, + useNeutralTeamColors: + preferences.defaultNeutralTeamColorsForNewStrategies, + ), + ); + _syncMapSide(); + } + + /// 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; + final current = ref.read(mapProvider); + if (current.currentMap == map && current.isAttack == isAttack) return; + ref.read(mapProvider.notifier).fromHive(map, isAttack); + } + + @override + void dispose() { + _progressTimer?.cancel(); + _heightRuntime?.close(); + _loader.cancel(); + _ticker.dispose(); + _coneCuts?.dispose(); + _playback + ?..removeListener(_syncMapSide) + ..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!, + onReturnFromEditor: claimEditorState, + ) + : _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(' · '), + // DESIGN.md body role, the size of the editor's title. + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + 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, + required this.onReturnFromEditor, + }); + + final ReplayPlayback playback; + final MapValue map; + final VoidCallback onReturnFromEditor; + + @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, + onReturnFromEditor: onReturnFromEditor, + ), + ), + ), + 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/strategy_view.dart b/lib/strategy_view.dart index e16c8eac..0fe95fe6 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -45,6 +45,8 @@ class StrategyView extends ConsumerStatefulWidget { this.initialStrategySource = StrategySource.local, this.initialMapValue, this.initialIsAttack = true, + this.initialPageId, + this.backTooltip, }); final String? initialStrategyId; @@ -53,12 +55,22 @@ class StrategyView extends ConsumerStatefulWidget { final MapValue? initialMapValue; final bool initialIsAttack; + /// 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, StrategySource initialStrategySource = StrategySource.local, MapValue? initialMapValue, bool initialIsAttack = true, + String? initialPageId, + String? backTooltip, }) { return PageRouteBuilder( settings: const RouteSettings(name: Routes.strategyView), @@ -70,6 +82,8 @@ class StrategyView extends ConsumerStatefulWidget { initialStrategySource: initialStrategySource, initialMapValue: initialMapValue, initialIsAttack: initialIsAttack, + initialPageId: initialPageId, + backTooltip: backTooltip, ), transitionsBuilder: (context, animation, _, child) { return FadeTransition( @@ -156,7 +170,9 @@ class _StrategyViewState extends ConsumerState try { switch (source) { case StrategySource.local: - await ref.read(strategyProvider.notifier).loadFromHive(strategyId); + await ref + .read(strategyProvider.notifier) + .loadFromHive(strategyId, pageId: widget.initialPageId); case StrategySource.cloud: final viewingThroughLink = ref.read(shareLinkViewProvider)?.strategyPublicId == strategyId; @@ -285,7 +301,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, @@ -294,7 +311,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/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 24a81fa2..09ab2238 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -726,18 +726,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!; final reach = Rect.fromCircle(center: origin, radius: range); @@ -787,7 +811,9 @@ class SvgHeightVisibility { sector: sector); } - 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 00000000..ab5a141e --- /dev/null +++ b/lib/view_cone/vision_occluders.dart @@ -0,0 +1,177 @@ +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; + // 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, + for (var step = steps; step >= 0; step--) + origin + + Offset(math.cos(fromAngle + sweep * step / steps), + math.sin(fromAngle + sweep * step / steps)) * + far, + ]); + } + 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/ability/ability_range_opacity.dart b/lib/widgets/draggable_widgets/ability/ability_range_opacity.dart new file mode 100644 index 00000000..0a424195 --- /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 9e04f11c..4716bbed 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 242f1d78..ceb6e98b 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 13682f9a..91ee2f4c 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 3bbf3df3..d0a52d5e 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 5a070c7d..e7b0109d 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 954cd96d..350e54fe 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 3882e8a0..f17d46ee 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/draggable_widgets/utilities/svg_height_view_cone.dart b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart index 394aebe7..81eb82e3 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); @@ -293,6 +308,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 final _identity = Float64List(16) ..[0] = 1 ..[5] = 1 diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 94d381b8..32219eeb 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -29,6 +29,7 @@ import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/replay/replay_library_content.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -160,6 +161,8 @@ class FolderContent extends ConsumerWidget { switch (tab) { case LibraryTab.community: return _buildCommunityPlaceholder(context, ref); + case LibraryTab.replays: + return const ReplayLibraryContent(); case LibraryTab.shared: return _crossFade(_buildCloudBody(context, ref)); case LibraryTab.library: diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index d1b5079a..7af09c4c 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; @@ -5,7 +6,9 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/library_navigation_provider.dart'; import 'package:icarus/providers/library_workspace_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/services/guarded_sign_out.dart'; import 'package:icarus/widgets/account_avatar.dart'; import 'package:icarus/widgets/custom_search_field.dart'; @@ -97,6 +100,19 @@ class _LibraryTitleStripState extends ConsumerState { onTap: navigation.showLibrary, ), const SizedBox(width: _tabGap), + if (ref + .watch(platformPolicyProvider) + .supports(PlatformFeature.replays)) ...[ + _TabButton( + key: const ValueKey('library-tab-replays'), + icon: LucideIcons.film, + label: 'Replays', + semanticsLabel: 'Replays', + selected: tab == LibraryTab.replays, + onTap: navigation.showReplays, + ), + const SizedBox(width: _tabGap), + ], _TabButton( key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, @@ -136,7 +152,10 @@ class _LibraryTitleStripState extends ConsumerState { ), const WhatsNewIcon(), const SizedBox(width: 4), - if (tab != LibraryTab.community && !signInRequired) ...[ + if (tab == LibraryTab.replays) ...[ + _buildAddReplayButton(), + const SizedBox(width: 8), + ] else if (tab != LibraryTab.community && !signInRequired) ...[ const SizedBox( height: _controlHeight, child: SearchTextField( @@ -172,6 +191,40 @@ class _LibraryTitleStripState extends ConsumerState { ); } + /// 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'), + ); + } + Widget _buildSortMenu() { final filter = ref.watch(strategyFilterProvider); final isAscending = filter.sortOrder == SortOrder.ascending; diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index e11095cb..b2d934b3 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -803,7 +803,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final widget in orderedWidgets) - _widgetView( + staticPlacedWidgetView( widget: widget, mapScale: mapScale, abilitySize: abilitySize, @@ -814,116 +814,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 00000000..abbfc1a1 --- /dev/null +++ b/lib/widgets/replay/replay_canvas.dart @@ -0,0 +1,317 @@ +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/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'; +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. +/// 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(); +} + +/// How strongly ranges and areas players stand in are drawn, so the agents +/// and cones beneath stay readable. Their icons stay solid. +const _rangeOpacity = 0.3; + +/// [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, + top: view.top, + right: view.right, + bottom: view.bottom, + width: view.width, + height: view.height, + 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; + + @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: [ + ShadTheme.of(context).colorScheme.card, + 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); + 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: [ + 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, + ), + ), + ), + ), + // 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, height), + placed: [ + for (final placed in widgets) + if (placed is! PlacedAgentNode) placed, + ], + dimmed: frame.dimmed, + view: view, + ), + ), + for (final exit in exits) + _faded( + exit.dimmed + ? _faintRange(view(exit.ability)) + : view(exit.ability), + 1 - exit.progress, + ), + 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, + apertureDegrees: cuts.apertureDegrees, + ), + ), + ), + ), + for (final placed in widgets) + if (placed is PlacedAgentNode) + cuts == null + ? view(placed) + : ViewConesDrawnElsewhere( + key: ValueKey(placed.id), + child: view(placed), + ), + ], + ); + }, + ), + ), + ), + ); + }, + ); + } +} + +/// 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) + ? _faintRange(widget.view(placed)) + : 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 00000000..82c64699 --- /dev/null +++ b/lib/widgets/replay/replay_cones.dart @@ -0,0 +1,191 @@ +import 'dart:math' as math; +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, + required this.apertureDegrees, + }); + + 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; + + /// The cones' spread; cuts see all the way round. + final double apertureDegrees; + + 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; + 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, 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 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.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. + final reachRect = Rect.fromCircle(center: origin, radius: reach); + paintSvgConeArea( + canvas, + cone, + _conePaths[cone] ??= Path()..addPolygon(cone.polygon, true), + Paint() + ..shader = RadialGradient( + colors: [_coneGrey.withValues(alpha: .5), Colors.transparent], + ).createShader(reachRect), + reachRect, + ); + canvas.restore(); + if (shadows != null) { + canvas.save(); + canvas.transform(sourceToScreen); + canvas.drawPath(shadows, Paint()..blendMode = BlendMode.clear); + canvas.restore(); + canvas.restore(); + } + } + 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) / + 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); + } + + /// 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 new file mode 100644 index 00000000..71c14add --- /dev/null +++ b/lib/widgets/replay/replay_dock.dart @@ -0,0 +1,356 @@ +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: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 +/// 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), + ), + // 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( + playing ? LucideIcons.pause : LucideIcons.play, + size: 18, + ), + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 40, + child: ListenableBuilder( + listenable: playback, + builder: (context, _) => Text( + _roundClock(playback), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + ), + 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), + ), + ], + ), + ], + ), + ); + } + + /// 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) { + const 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_effects.dart b/lib/widgets/replay/replay_effects.dart new file mode 100644 index 00000000..c7be0792 --- /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_library_content.dart b/lib/widgets/replay/replay_library_content.dart new file mode 100644 index 00000000..eb152f3a --- /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 00000000..17bfea32 --- /dev/null +++ b/lib/widgets/replay/replay_match_card.dart @@ -0,0 +1,258 @@ +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'; +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 +/// moment into a strategy. +class ReplayMatchCard extends ConsumerStatefulWidget { + 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( + createStrategy: (map, name) => ref + .read(strategyProvider.notifier) + .createNewStrategy(map: map, name: name, local: true), + map: widget.map, + name: '${replayMapName(widget.map)} replay · ' + '${_dateLabel(widget.playback.document.match.recordedAt)}', + ); + bool _capturing = false; + + 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)); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + "Couldn't capture this moment.", + error: error, + stackTrace: stackTrace, + source: 'ReplayMatchCard.capture', + ); + } finally { + if (mounted) setState(() => _capturing = false); + } + } + + /// Opens the strategy on the page just captured. + Future _openStrategy() async { + final strategy = _capture.strategy; + if (strategy == null) return; + widget.playback.pause(); + 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: page.isAttack, + initialPageId: page.id, + backTooltip: 'Back to replay', + ), + ); + if (!mounted) return; + widget.onReturnFromEditor(); + setState(() {}); + } + + @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), + ), + // The round, the side and the score change a few times a round, not + // every frame. + child: ReplaySelect( + listenable: widget.playback, + select: () => ( + widget.playback.round, + _score(widget.playback), + widget.playback.frame.isAttack, + ), + builder: (context, shown) { + final playback = widget.playback; + final (round, score, isAttack) = shown; + final captured = _capture.strategy?.pages.length ?? 0; + 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 decided by the moment being watched. + ({int ally, int enemy}) _score(ReplayPlayback playback) { + var ally = 0; + var enemy = 0; + for (final round in playback.document.rounds) { + if (round.endMs > playback.timeMs) 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 00000000..5d034adf --- /dev/null +++ b/lib/widgets/replay/replay_roster.dart @@ -0,0 +1,359 @@ +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: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. +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: ReplaySelect>( + listenable: playback, + select: _shown, + 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)), + ], + ); + }, + ), + ); + } +} + +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, + 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'; + final credits = economy?.credits; + 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: 14), + ), + const SizedBox(height: 4), + _VitalsBar(health: state.health, armor: state.armor), + ], + ), + ), + 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, + children: [ + if (weapon != WeaponType.none) + Image.asset(weapon.iconPath, height: 14) + else + const SizedBox(height: 14), + 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, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +/// 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}); + + 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 ?? 0) / 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?.team?.colorFor(perspective) ?? + Settings.tacticalVioletTheme.border, + ), + ), + child: + agent == null ? null : Image.asset(AgentData.agents[agent]!.iconPath), + ); + } +} diff --git a/lib/widgets/replay/replay_select.dart b/lib/widgets/replay/replay_select.dart new file mode 100644 index 00000000..bcf50760 --- /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/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 80a798de..d53e05a5 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 00000000..2f7896d1 --- /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 00000000..d0cb5f33 --- /dev/null +++ b/native/replay/CMakeLists.txt @@ -0,0 +1,31 @@ +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) +# 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 + --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 00000000..18b20e5e --- /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.2.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 00000000..67e39dd9 --- /dev/null +++ b/native/replay/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "icarus_replay" +# 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" +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 00000000..65900e39 --- /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 00000000..51883a0b --- /dev/null +++ b/native/replay/build_macos.sh @@ -0,0 +1,55 @@ +#!/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, 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}" +if [ ! -x "$cargo" ]; then + 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" "$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. +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 + 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" +fi diff --git a/native/replay/include/icarus_replay.h b/native/replay/include/icarus_replay.h new file mode 100644 index 00000000..010503b1 --- /dev/null +++ b/native/replay/include/icarus_replay.h @@ -0,0 +1,74 @@ +/* + * icarus_replay: decodes a Valorant .vrf replay into the buffer described in + * 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: + * { "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); + +/* + * 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. + */ +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); + +#ifdef __cplusplus +} +#endif + +#endif /* ICARUS_REPLAY_H */ diff --git a/native/replay/rust-toolchain.toml b/native/replay/rust-toolchain.toml new file mode 100644 index 00000000..70707a55 --- /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 new file mode 100644 index 00000000..74c252fa --- /dev/null +++ b/native/replay/src/analysis/casts.rs @@ -0,0 +1,220 @@ +//! 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, 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}; + +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, +} + +/// 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. + 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 = 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.clone(), round, slot, cast_time.to_bits())) { + found.push(Found { + first_send: time, + player, + slot, + round, + cast_time, + location: state.location, + }); + } + } + + // `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, Found)> = found + .into_iter() + .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(f.first_send)), f) + }) + .collect(); + let classes = slot_classes(cx, &timed); + let mut casts: Vec = timed + .into_iter() + .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))); + if unplaced > 0 { + cx.warnings.push(format!( + "{unplaced} cast(s) in a round with no buy-phase end; timed by first send" + )); + } + 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, Found)]) -> 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, Found { player, slot, .. }) in casts { + let slot = *slot; + 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 00000000..94335fb5 --- /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 00000000..1aee6133 --- /dev/null +++ b/native/replay/src/analysis/mod.rs @@ -0,0 +1,453 @@ +//! 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, Loss, 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) +} + +/// 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, + 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(), + 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, &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); + + 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", 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), + ("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, + loss, + 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 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)]; + 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 00000000..2eee9176 --- /dev/null +++ b/native/replay/src/analysis/movement.rs @@ -0,0 +1,116 @@ +//! Movement records (docs/replay-format.md, "Movement records"): every +//! 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, HashMap}; + +use super::Context; +use crate::collect::Move; +use crate::container::MovementSample; +use crate::document::MovementSpan; + +/// 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: 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(); + 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(); + // 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, + }, + ); + } + // 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!( + "{lost} movement record(s) of agent bodies no player claims (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 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 new file mode 100644 index 00000000..202c545f --- /dev/null +++ b/native/replay/src/analysis/rounds.rs @@ -0,0 +1,573 @@ +//! 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, + /// Phases sent again: (phase, kept time, repeat time). + repeats: Vec<(i64, u32, u32)>, +} + +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, + }) + } + + /// 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, + 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 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; + } + // 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 p = phases.last_mut().expect("pushed"); + let slot = p.slot(phase).expect("2..=6"); + match *slot { + None => *slot = Some(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))); + } 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())); + } + } + let phases = started(cx, phases); + + let opens: Vec = phases + .iter() + .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(); + 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, ((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); + 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_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; + // 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 => { + if winning_team.is_none() { + cx.warnings.push(format!( + "round {ordinal} never ended: the replay stops inside it" + )); + } + cx.duration_ms + } + }; + 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 +} + +/// 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: &[(u32, 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(), + weapon: gun(false).or_else(|| gun(true)), + armor: items.iter().filter_map(|c| armor_points(c)).max(), + } + }) + .collect() +} + +#[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() { + 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 00000000..54fb6210 --- /dev/null +++ b/native/replay/src/analysis/utility.rs @@ -0,0 +1,396 @@ +//! 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`). 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, HashSet}; + +use vrf_export::ActorRecord; + +use super::{Context, parse_vector, raw_vector, value_at}; +use crate::collect::Move; +use crate::document::{Utility, Vec3}; + +/// 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) { + tracks.entry(row.actor).or_default().push([ + f64::from(row.time), + loc[0], + loc[1], + loc[2], + ]); + } + } + // Phoenix's Blaze and Viper's Toxic Screen. + "MulticastAddSmokeScreenPoint.Translation" => { + 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 (lives, reopened) = lives(cx.actors); + let mut out: Vec = Vec::new(); + 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 { + let within = |t: f64| t >= u.spawn_ms as f64 && u.end_ms.is_none_or(|e| t <= e as f64); + 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[..]) { + // 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(), + ); + } + } + 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 open(s) for an actor already open and awake (kept as one)", + ), + ( + no_owner, + "utility actor(s) spawned in play with no player owner", + ), + ] { + if n > 0 { + cx.warnings.push(format!("{n} {what}")); + } + } + 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. +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 { + 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 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 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( + "/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 00000000..6c833b23 --- /dev/null +++ b/native/replay/src/analysis/vitals.rs @@ -0,0 +1,499 @@ +//! Health and armour per player (docs/replay-format.md, "vitals"). +//! +//! 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, HashSet}; + +use super::Context; +use crate::collect::Row; +use crate::document::{Round, VitalsRow}; +use crate::fieldpath::FieldPath; + +/// Health every body starts a round with. +const FULL_HEALTH: f64 = 100.0; + +/// 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, + }, +} + +/// 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<(Order, Change)> = rounds + .iter() + .filter_map(|r| Some(((u32::try_from(r.start_ms).ok()?, 0, 0), Change::RoundStart))) + .collect(); + + // 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(); + // An item's own `Owner` writes, as `Context::owner_writes` reads them. + for (i, row) in cx.rows.iter().enumerate() { + if &*row.name != "Owner" || row.object.is_some() || !full.contains_key(&row.actor) { + continue; + } + let Some(owner) = row.int.and_then(|v| u32::try_from(v).ok()) else { + continue; + }; + if let Some(subject) = cx.body_subject.get(&owner) { + let subject = subject.clone(); + let item = row.actor; + changes.push(( + (row.time, row.packet, i + 1), + Change::Assigned { subject, item }, + )); + } + } + for a in cx.actors { + if a.event == "close" && full.contains_key(&a.actor_net_guid) { + // After its packet's rows, which a close ends. + changes.push(( + (a.time_ms, a.packet_id, usize::MAX), + Change::Closed { + item: a.actor_net_guid, + }, + )); + } + } + + // 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<(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((at, section, value)); + } + continue; + } + let path = FieldPath::parse(&row.name); + if path.segments.len() != 3 + || !matches!( + path.segments[1].base, + "LifeChangeEvents" | "LifeChangeBySection" + ) + { + 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((at, 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 (at, key) in order { + if let (Some(section), Some(value)) = elements[&key] { + results.push((at, section, value)); + } + } + results +} + +/// `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); + changes + .into_iter() + .map(|((time, _, _), c)| (time, c)) + .collect() +} + +/// One life-change element: (packet, actor, component, element indices, +/// function). +type ElementKey<'r> = (u32, u32, Option, Vec>, &'r str); + +/// 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(); + 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 { + if change == Change::RoundStart { + round_ms = Some(time); + started = true; + } + 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() + } + }; + if !started { + continue; + } + 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_ms == Some(time)); + } + } + out +} + +/// 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); +} + +/// 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) + ] + ); + } + + 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![ + (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 new file mode 100644 index 00000000..0be47192 --- /dev/null +++ b/native/replay/src/bin/icarus-replay-dump.rs @@ -0,0 +1,299 @@ +//! Dev tool: decode one replay and write what came out, for inspection. +//! +//! 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. +//! `--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. +//! Decompresses with the build's decoder (`oodle::default_decompressor`). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use icarus_replay::decode::{Control, Observer}; +use icarus_replay::{ReplayError, 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 | --rows=A,B] [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) -> Result<(), ReplayError> { + *self.events.entry(event.group).or_default() += 1; + Ok(()) + } + + 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()); + *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!( + "{}\t{}\t{}\t{}\t{}\t{name}\t{value}\n", + 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)); + } + } + Ok(()) + } +} + +/// `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!("{}\t{outer}\t{}\n", 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 00000000..69e32ceb --- /dev/null +++ b/native/replay/src/collect.rs @@ -0,0 +1,246 @@ +//! 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, 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 +/// 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, + /// The payload of a value vrfkit left untyped, whole bytes only. + pub raw: 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, + /// `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) -> 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, + ) -> 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, + 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 { + 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; + } + let row = 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(), + 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(()) + } +} + +/// 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", + "MulticastAddTunnelPoint.Translation", + "MulticastInitializeWall.WallStartLocation", + "MulticastInitializeWall.WallEndLocation", + "MulticastInitializeTrapAnchors.WallStartPoint", + "MulticastInitializeTrapAnchors.WallEndPoint", + "MulticastInitialize.Destination", + "MulticastNotifySetLife.NewLife", + "MulticastNotifyDamage_Point.bDamageKilledTarget", + "MulticastNotifyDamage_Point.EquippableUsed", + "MulticastNotifyDamage_Base.bDamageKilledTarget", + "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; + } + 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") + } + } +} + +#[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/container.rs b/native/replay/src/container.rs new file mode 100644 index 00000000..417ea7b9 --- /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 00000000..24525190 --- /dev/null +++ b/native/replay/src/decode.rs @@ -0,0 +1,258 @@ +//! 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, 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; + +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::limits; +use crate::oodle::{Decompressor, inflate_replay_data}; +use crate::vendor::pass::Pass; +use crate::vendor::sink::{ExportStats, PlayerIdentity, RecordBuffers}; + +/// 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. + 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. 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) -> Result<(), ReplayError>; + /// One packet's records; `cache` is the GUID table as of that packet. + fn on_packet( + &mut self, + records: &RecordBuffers, + cache: &NetGuidCache, + ) -> Result<(), ReplayError>; +} + +/// 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 unknown_chunks: u32, + pub packets: 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(), + unknown_chunks: 0, + packets: 0, + event_layout_mismatches: 0, + 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). + 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))?; + match kind { + ChunkType::ReplayData => { + let Ok(inflated_chunk) = rx.recv() else { + // The helper stops early only when cancelled. + control.check()?; + return Err(ReplayError::new( + ErrorCode::Corrupt, + "decompression stopped", + )); + }; + 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)?; + 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(()) + }) + .map_err(|e| match e.build { + Some(_) => e, + None => e.with_build(build), + })?; + + pass.finish(); + 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(); + 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 00000000..82874052 --- /dev/null +++ b/native/replay/src/document.rs @@ -0,0 +1,236 @@ +//! 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, 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")] +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, + 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 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, + /// `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, + #[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, + /// 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::*; + + #[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/src/error.rs b/native/replay/src/error.rs new file mode 100644 index 00000000..a05e2118 --- /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 00000000..dc481d77 --- /dev/null +++ b/native/replay/src/ffi.rs @@ -0,0 +1,240 @@ +//! The C ABI (include/icarus_replay.h). The only `unsafe` in the crate: it +//! 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, Ordering}; + +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")) + }) +} + +/// 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`. `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, + control: *const IcarusReplayControl, +) -> IcarusReplayBuffer { + // SAFETY: forwarded caller contract. + let path = unsafe { path_from(path_utf8) }; + // 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`. +/// 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; 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"); + // 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 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())); + 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 00000000..2555b323 --- /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 00000000..0993ab9c --- /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 00000000..0b817839 --- /dev/null +++ b/native/replay/src/header.rs @@ -0,0 +1,231 @@ +//! 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)?; + // 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 { + 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.2.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 00000000..d706e62e --- /dev/null +++ b/native/replay/src/lib.rs @@ -0,0 +1,165 @@ +//! 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 limits; +pub mod oodle; +pub mod selkie; +mod vendor; + +use std::io::Read; +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 = 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( + 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}")))?; + 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 00000000..3c8b67c4 --- /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 new file mode 100644 index 00000000..53aeaa8f --- /dev/null +++ b/native/replay/src/oodle.rs @@ -0,0 +1,323 @@ +//! 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`]. 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::limits; +use crate::selkie::Selkie; + +/// 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: 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!("no Oodle decoder (a {decompressed_size}-byte archive needs one)"), + )) + } +} + +/// 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") { + return match VectorDecompressor::open(std::path::Path::new(&dir)) { + Ok(vectors) => Box::new(vectors), + Err(error) => Box::new(Broken(error)), + }; + } + Box::new(Selkie) +} + +/// 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 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", + 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_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(); + 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/selkie.rs b/native/replay/src/selkie.rs new file mode 100644 index 00000000..df0013af --- /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/src/vendor/frame.rs b/native/replay/src/vendor/frame.rs new file mode 100644 index 00000000..d9390b6e --- /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 new file mode 100644 index 00000000..6041c279 --- /dev/null +++ b/native/replay/src/vendor/mod.rs @@ -0,0 +1,21 @@ +//! 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`). +//! - `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 new file mode 100644 index 00000000..60315709 --- /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), 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_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 +/// 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`. `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<(), FrameError> { + let branch = self.branch; + let Self { + cache, + reader, + channels, + buffers, + packets, + .. + } = self; + 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; + 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; + 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(()) + } + + /// 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 00000000..38ad8b4c --- /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 00000000..caf46b99 --- /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 00000000..3cda02c1 --- /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 00000000..6f2ac75b --- /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 00000000..557667ca --- /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 00000000..78c5e6bb --- /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 00000000..9e3341fa --- /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 00000000..5a661057 --- /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 00000000..f224ecce --- /dev/null +++ b/native/replay/tests/corpus.rs @@ -0,0 +1,398 @@ +//! 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}; + +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 { + 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) + .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 +} + +/// 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] +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() { + 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: 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 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"); + + let rounds = doc["rounds"].as_array().unwrap(); + 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!( + [3, 4, 5, 9].contains(&c["slot"].as_i64().unwrap()), + "{id}: cast {c}" + ); + } + } +} + +/// 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(|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; + } + } +} + +/// 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}"); + } + _ => {} + } + } +} + +/// 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() { + 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() { + 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() { + 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() { + 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/native/replay/tests/selkie_vectors.rs b/native/replay/tests/selkie_vectors.rs new file mode 100644 index 00000000..fbae986b --- /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() +} diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index ba213d0e..00754994 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -127,6 +127,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" diff --git a/test/ability_visibility_widgets_test.dart b/test/ability_visibility_widgets_test.dart index 98d603c8..67a1f6ea 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(); diff --git a/test/mouse_navigation_test.dart b/test/mouse_navigation_test.dart index dbe58d46..6988aaa7 100644 --- a/test/mouse_navigation_test.dart +++ b/test/mouse_navigation_test.dart @@ -227,7 +227,7 @@ class _RecordingStrategies extends StrategyProvider { } @override - Future loadFromHive(String id) async { + Future loadFromHive(String id, {String? pageId}) async { opened.add('local:$id'); show(id, StrategySource.local); } diff --git a/test/replay_ability_catalog_test.dart b/test/replay_ability_catalog_test.dart new file mode 100644 index 00000000..7ed1e32a --- /dev/null +++ b/test/replay_ability_catalog_test.dart @@ -0,0 +1,845 @@ +import 'dart:math' as math; + +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'; +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/ability_widget.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)); + + /// 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)) + 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)); + }); + + 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); + 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 drawn = await measureSquare(tester, lane, projection); + final anchor = drawnAnchor(lane, projection); + final direction = unitFor(lane.rotation); + + expect(anchor + direction * drawn.gap, near(screenOf(projection, start))); + expect(anchor + direction * (drawn.gap + drawn.length), + near(screenOf(projection, end))); + }); + + 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(960, -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 drawn = await measureSquare(tester, fissure, projection); + + expect( + 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. + 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/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': + (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/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_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), + '/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_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_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_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_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_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_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_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', + '/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_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_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_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_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_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_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_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/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_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_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_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_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_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_capture_test.dart b/test/replay_capture_test.dart new file mode 100644 index 00000000..97160599 --- /dev/null +++ b/test/replay_capture_test.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +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); + }); + + 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_document_test.dart b/test/replay_document_test.dart new file mode 100644 index 00000000..e584c6d1 --- /dev/null +++ b/test/replay_document_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/replay/replay_document.dart'; + +import 'replay_test_support.dart'; + +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 00000000..62c26ff3 --- /dev/null +++ b/test/replay_files_test.dart @@ -0,0 +1,95 @@ +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('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'); + 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_frame_test.dart b/test/replay_frame_test.dart new file mode 100644 index 00000000..007bac94 --- /dev/null +++ b/test/replay_frame_test.dart @@ -0,0 +1,485 @@ +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/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'; +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'; + +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 {}, + Map firstRound = 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', + ...firstRound, + }, + {'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() { + 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('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, + '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('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, 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()); + // 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); + }); + + 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( + 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(), + 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); + }); + + 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 playback = ReplayPlayback( + document: walkers(subjects), + 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(); + expect( + 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.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. + 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.placed); + }); + + 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 + ..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_map_projection_test.dart b/test/replay_map_projection_test.dart new file mode 100644 index 00000000..213c5498 --- /dev/null +++ b/test/replay_map_projection_test.dart @@ -0,0 +1,325 @@ +import 'dart:convert'; +import 'dart:io'; +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/view_cone/svg_height_visibility.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)); + }); + + // 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 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), closeTo(6.5, .01)); + expect(elevation(MapValue.split, x, y, z), isNull); + }); + + 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.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)'); + } + }); + }); +} diff --git a/test/replay_test_support.dart b/test/replay_test_support.dart new file mode 100644 index 00000000..0fbd1393 --- /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}, + }; + diff --git a/test/vision_occluders_test.dart b/test/vision_occluders_test.dart new file mode 100644 index 00000000..ab87b0fa --- /dev/null +++ b/test/vision_occluders_test.dart @@ -0,0 +1,81 @@ +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('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, + 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, + ); + }); +} diff --git a/test/widgets/web_beta_library_test.dart b/test/widgets/web_beta_library_test.dart index 61c001d3..44ad39f7 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'; @@ -298,10 +299,12 @@ void main() { expect(find.byType(WebBetaDialog), findsOneWidget); expect(find.text('Icarus web beta'), findsOneWidget); + // Replays need the desktop's decoder and files on disk. + expect(find.byKey(const ValueKey('library-tab-replays')), findsNothing); expect(find.text('Desktop-only for now'), findsOneWidget); expect( find.text( - 'Export · Import · Drag and drop', + 'Export · Import · Drag and drop · Replays', ), findsOneWidget, ); @@ -326,6 +329,7 @@ void main() { expect(find.byKey(const ValueKey('library-sign-in-state')), findsNothing); expect(find.byKey(const ValueKey('web-beta-tag')), findsNothing); expect(find.byKey(const ValueKey('library-new-strategy')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab-replays')), findsOneWidget); await tester.tap(find.byKey(const ValueKey('library-new-menu'))); await tester.pumpAndSettle(); @@ -346,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', () { diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index e43851d4..22d969e0 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,12 @@ 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(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)