diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6310e0e3f..6ea676ae9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,7 +1,9 @@ -# The checks a contributor can pass without the original game: the repo's content gate, dotnet -# format, StyleCop, and the unit tests (tests needing extracted game data skip themselves). The -# engine suites and goldens need Godot and an install, so they stay with RunTests.ps1 locally. -# Three OSes, because the scripts must work wherever a contributor runs them. +# The checks a contributor can pass without the original game. The checks job runs the repo's +# content gate, dotnet format, StyleCop, and the unit tests (tests needing extracted game data skip +# themselves) on three OSes, because the scripts must work wherever a contributor runs them. The +# engine job runs the in-engine suites on the ci tier headless on Linux with no extraction, through +# RunCiSuites.ps1; a listed suite that fails or skips turns it red. The rest of the engine catalog, +# the goldens and the hitch check need an install or a renderer, so they stay with RunTests.ps1. name: Checks on: @@ -75,3 +77,95 @@ jobs: - name: Unit tests run: dotnet test CSVM/CSVM.sln --nologo + + engine: + name: engine suites (tier:ci, headless) + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + shell: pwsh + env: + GODOT_RELEASE: 4.7-stable + GODOT_BUILD: Godot_v4.7-stable_mono_linux_x86_64 + GODOT_EXE: Godot_v4.7-stable_mono_linux.x86_64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + # Keyed on the release, so a version bump downloads and verifies afresh. A cached copy was + # verified against the release's SHA512-SUMS.txt before it was stored. + - name: Cache Godot + id: godot-cache + uses: actions/cache@v4 + with: + path: ~/godot + key: godot-${{ env.GODOT_BUILD }} + + - name: Download and verify Godot + if: steps.godot-cache.outputs.cache-hit != 'true' + run: | + $base = "https://github.com/godotengine/godot/releases/download/$env:GODOT_RELEASE" + $zip = "$env:GODOT_BUILD.zip" + $dir = Join-Path $HOME 'godot' + New-Item -ItemType Directory -Force $dir | Out-Null + Set-Location $dir + curl -fsSL --retry 3 -o $zip "$base/$zip" + if ($LASTEXITCODE) { exit $LASTEXITCODE } + curl -fsSL --retry 3 -o SHA512-SUMS.txt "$base/SHA512-SUMS.txt" + if ($LASTEXITCODE) { exit $LASTEXITCODE } + $rows = @(Get-Content SHA512-SUMS.txt | Where-Object { ($_ -split '\s+')[-1].TrimStart('*') -eq $zip }) + if ($rows.Count -ne 1) { throw "SHA512-SUMS.txt lists $zip $($rows.Count) times, not once" } + $want = ($rows[0] -split '\s+')[0].ToLowerInvariant() + $got = (Get-FileHash -Algorithm SHA512 $zip).Hash.ToLowerInvariant() + if ($got -ne $want) { throw "$zip has SHA-512 $got; the release publishes $want" } + Write-Host "$zip matches the SHA-512 the release publishes" + # unzip, not Expand-Archive, which drops the executable bit. + unzip -q $zip + if ($LASTEXITCODE) { exit $LASTEXITCODE } + chmod +x "$env:GODOT_BUILD/$env:GODOT_EXE" + Remove-Item $zip, SHA512-SUMS.txt + + - name: Locate Godot + run: | + $godot = Join-Path $HOME "godot/$env:GODOT_BUILD/$env:GODOT_EXE" + if (-not (Test-Path $godot)) { throw "No Godot executable at $godot" } + "GODOT=$godot" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + # Without fontconfig Godot's Linux build logs an engine error on every font lookup, and + # RunCiSuites.ps1 refuses to start. Installed only when the runner image lacks it. + - name: fontconfig + run: | + if (-not (@(/sbin/ldconfig -p) | Where-Object { $_ -match 'libfontconfig\.so\.1' })) { + sudo apt-get update + sudo apt-get install -y libfontconfig1 + } + + - name: Build + run: dotnet build CSVM/CSVM.sln --nologo + + - name: Import + run: | + & $env:GODOT --headless --path CSVM --import + if ($LASTEXITCODE) { exit $LASTEXITCODE } + + - name: Engine suites + run: ./RunCiSuites.ps1 -Godot $env:GODOT + + - name: Upload report and logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: engine-suites + # .scratch is a hidden folder, which upload-artifact skips unless told otherwise. + include-hidden-files: true + if-no-files-found: warn + path: | + .scratch/test-report.json + .scratch/ci-suites/godot.out + .scratch/ci-suites/godot.err + .scratch/ci-suites/godot.log + .scratch/logs/ diff --git a/CSVM.Tests/LoadoutTests.cs b/CSVM.Tests/LoadoutTests.cs index 317c6734a..aa3993ad6 100644 --- a/CSVM.Tests/LoadoutTests.cs +++ b/CSVM.Tests/LoadoutTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using CSVM.Flight.Weapons; using Xunit; @@ -13,6 +14,11 @@ namespace CSVM.Tests; [Trait("Tier", "Quick")] public class LoadoutTests { + // Every firepoint and pylon the rig can carry, the shape the lab loadout covers. + private static readonly string[] FullRig = Enumerable.Range(1, 8) + .SelectMany(i => new[] { $"firepoint{i}", $"pylon{i}" }) + .ToArray(); + private static string ConfigPath => Path.Combine(TestData.RepoRoot, "CSVM", "data", "stock_loadouts.json"); @@ -205,6 +211,43 @@ public void ArmedIsTheOneSpellingAndHonoursInfiniteAmmo() Assert.True(pylon.Armed(infinite: false)); } + [Fact] + public void TheRigLoadoutKeepsAWeaponTheStockFitNames() + { + // A named weapon sits outside the caliber matrix; rebuilding the slot from Caliber and Ammo + // alone binds a matrix weapon in its place. A slot stock leaves out takes the first gun's. + var stock = new LoadoutDef { Def = "rig_probe" }; + stock.Guns.Add(new GunSpec { Slot = 1, Caliber = 30, Ammo = "slug", WeaponId = "wep_named" }); + stock.Guns.Add(new GunSpec { Slot = 2, Caliber = 50, Ammo = "ap" }); + + var rig = Loadout.RigDef(FullRig, stock); + + Assert.Equal(new[] { 1, 2, 3, 4 }, rig.Guns.Select(g => g.Slot)); + Assert.Equal("wep_named", rig.Guns[0].WeaponId); + Assert.Null(rig.Guns[1].WeaponId); + Assert.Equal((50, "ap"), (rig.Guns[1].Caliber, rig.Guns[1].Ammo)); + Assert.Equal("wep_named", rig.Guns[2].WeaponId); + Assert.Equal(new[] { "firepoint7", "firepoint8" }, rig.Guns[0].Markers); + } + + [Fact] + public void EveryShippedFitsRigLoadoutStillComposesItsWeaponFromCaliberAndAmmo() + { + // No shipped fit names a weapon. Every rig slot therefore carries none, and Bind composes + // the matrix id from Caliber and Ammo. + foreach (var (def, loadout) in Load().All) + { + var rig = Loadout.RigDef(FullRig, loadout); + Assert.Equal(4, rig.Guns.Count); + foreach (var gun in rig.Guns) + { + var stockGun = loadout.Guns.FirstOrDefault(g => g.Slot == gun.Slot) ?? loadout.Guns[0]; + Assert.True(gun.WeaponId == null, $"{def} slot {gun.Slot}: {gun.WeaponId}"); + Assert.Equal((stockGun.Caliber, stockGun.Ammo), (gun.Caliber, gun.Ammo)); + } + } + } + private static StockLoadouts Load() => StockLoadouts.Load(ConfigPath); // A fit of N pylons, every one carrying the stock high explosive the hangar writes. diff --git a/CSVM.Tests/NameResolverTests.cs b/CSVM.Tests/NameResolverTests.cs index 1d52362bb..0791e9e71 100644 --- a/CSVM.Tests/NameResolverTests.cs +++ b/CSVM.Tests/NameResolverTests.cs @@ -328,6 +328,56 @@ public void AWildcardInstancedDefsMissedNameReachesNoOtherNode() Assert.Equal(1, resolver.GlobalTierRefused); } + [Fact] + public void APeekResolvesLikeTheDispatchAndTalliesNothing() + { + // A read-only query asks the same tiers: the anchor's own node, the world answering an + // exact def, and the refusal a shared def's instance meets. Only the dispatch's form counts. + var crate01 = Node("crate01"); + var crateHealthy = Node("healthy"); + var crate02 = Node("crate02"); + var stray = Node("door1"); + var resolver = Build( + (crate01, "crate01", null), + (crateHealthy, "healthy", crate01), + (crate02, "crate02", null), + (stray, "door1", null)); + var shared = Def("crate**", rootName: "healthy"); + var healthy = new List { "healthy" }; + var door = new List { "door1" }; + + Assert.Equal(new[] { crateHealthy }, resolver.PeekScoped(healthy, shared, crate01)); + Assert.Empty(resolver.PeekScoped(healthy, shared, crate02)); + Assert.Equal(new[] { stray }, resolver.PeekScoped(door, Def("crate01"), crate01)); + Assert.Equal(0, resolver.GlobalTierRefused); + Assert.Equal(resolver.PeekScoped(healthy, shared, crate02), resolver.ResolveScoped(healthy, shared, crate02)); + Assert.Equal(1, resolver.GlobalTierRefused); + } + + [Fact] + public void APeekClaimsNothingAndLeavesTheLaterClaimOwed() + { + // A peek that memoized its answer unclaimed would hand the real query a memo hit, and the + // node would never be claimed. So the peek writes no memo, and the real query claims as usual. + var boat = Node("boat1"); + var anchor = Node("harbour"); + var resolver = Build((anchor, "harbour", null), (boat, "boat1", anchor)); + var claimed = new List(); + resolver.Claimed = claimed.Add; + var def = Def("harbour"); + var path = new List { "boat1" }; + + Assert.Equal(new TestNode?[] { anchor }, resolver.PeekAnchors(def)); + Assert.Equal(new[] { boat }, resolver.PeekScoped(path, def, anchor)); + Assert.Equal(new[] { boat }, resolver.Peek(() => resolver.FindAll("boat1", null))); + Assert.Empty(claimed); + + Assert.Equal(new[] { boat }, resolver.ResolveScoped(path, def, anchor)); + Assert.Equal(new[] { boat }, claimed); + Assert.Equal(new TestNode?[] { anchor }, resolver.Anchors(def)); + Assert.Contains(anchor, claimed); + } + [Fact] public void AnExactlyNamedDefStillFallsThroughToTheGlobalTier() { diff --git a/CSVM.Tests/OceanBaseTextureTests.cs b/CSVM.Tests/OceanBaseTextureTests.cs new file mode 100644 index 000000000..bd63bcda6 --- /dev/null +++ b/CSVM.Tests/OceanBaseTextureTests.cs @@ -0,0 +1,33 @@ +using CSVM.Mech3; +using Xunit; + +namespace CSVM.Tests; + +/// +/// Which water textures are the open-sea base sheet the Enhanced wave ocean replaces +/// (). C1 and C1C paint their sea with +/// water1, and C1's opaque coast tiles water1_trans1/2 sit beside it and must stay. +/// +public class OceanBaseTextureTests +{ + [Theory] + [InlineData("wtr00000.tif")] + [InlineData("WTR00000.TIF")] + [InlineData("wtr00000")] + [InlineData("water1.tif")] + [InlineData("water1")] + [InlineData("Water1.tif")] + public void TheBaseSheetIsMatched(string texture) => Assert.True(SceneBuilder.IsOceanBaseTexture(texture)); + + [Theory] + [InlineData("water1_trans1.tif")] + [InlineData("water1_trans2.tif")] + [InlineData("water10.tif")] + [InlineData("water2.tif")] + [InlineData("srf0001.tif")] + [InlineData("wakefront1.tif")] + [InlineData("z3_foggrad.tif")] + [InlineData("beach1.tif")] + [InlineData("")] + public void CoastTilesSurfAndOverlaysAreNot(string texture) => Assert.False(SceneBuilder.IsOceanBaseTexture(texture)); +} diff --git a/CSVM.Tests/OceanCalmZoneTests.cs b/CSVM.Tests/OceanCalmZoneTests.cs new file mode 100644 index 000000000..71a1d61fd --- /dev/null +++ b/CSVM.Tests/OceanCalmZoneTests.cs @@ -0,0 +1,91 @@ +using CSVM.Effects; +using Godot; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The ship calm zone the wave ocean flattens its waves in (). It is a +/// box along the hull's heading, grown over the waterline and the wake sheets' footprints. The +/// shader's smoothstep fades the waves back in past it. +/// +public class OceanCalmZoneTests +{ + private const float Eps = 1e-3f; + + [Fact] + public void AnEmptyZoneIsADiscAtItsOrigin() + { + var zone = new OceanCalmZone(new Vector2(100f, -40f), new Vector2(0f, 1f)); + Assert.True(zone.IsEmpty); + Assert.Equal(Vector2.Zero, zone.HalfExtents); + Assert.Equal(5f, zone.Distance(new Vector2(103f, -36f)), Eps); + } + + [Fact] + public void TheBoxRunsAlongTheHeading() + { + var zone = new OceanCalmZone(Vector2.Zero, new Vector2(0f, 3f)); + zone.Add(new Vector2(-10f, -100f)); + zone.Add(new Vector2(10f, 50f)); + Assert.Equal(new Vector2(0f, 1f), zone.Axis); + Assert.Equal(75f, zone.HalfExtents.X, Eps); + Assert.Equal(10f, zone.HalfExtents.Y, Eps); + Assert.Equal(0f, zone.Center.X, Eps); + Assert.Equal(-25f, zone.Center.Y, Eps); + Assert.Equal(0f, zone.Distance(new Vector2(9f, -99f)), Eps); + Assert.Equal(5f, zone.Distance(new Vector2(0f, 55f)), Eps); + Assert.Equal(5f, zone.Distance(new Vector2(-15f, 0f)), Eps); + // Past a corner the distance is to the corner, which rounds the zone's ends. + Assert.Equal(5f, zone.Distance(new Vector2(13f, 54f)), Eps); + } + + [Fact] + public void AMeshFootprintTurnsWithItsShip() + { + // A sheet 10 m wide and 100 m long, on a ship turned 30 degrees and moved off the origin. + var basis = new Basis(Vector3.Up, Mathf.DegToRad(30f)); + var ship = new Transform3D(basis, new Vector3(-5000f, 0f, -10000f)); + var sheet = new Aabb(new Vector3(-5f, 0.1f, -50f), new Vector3(10f, 9f, 100f)); + var heading = new Vector2(basis.Z.X, basis.Z.Z); + var zone = new OceanCalmZone(new Vector2(ship.Origin.X, ship.Origin.Z), heading); + zone.Add(sheet, ship); + Assert.Equal(50f, zone.HalfExtents.X, Eps); + Assert.Equal(5f, zone.HalfExtents.Y, Eps); + Assert.Equal(-5000f, zone.Center.X, 0.01f); + Assert.Equal(-10000f, zone.Center.Y, 0.01f); + var bow = new Vector2(ship.Origin.X, ship.Origin.Z) + (heading.Normalized() * 60f); + Assert.Equal(10f, zone.Distance(bow), 0.01f); + } + + [Fact] + public void AWakeBehindTheHullStretchesTheZoneOverBoth() + { + // The hull's waterline from -60 to 60 m along its heading, a wake sheet trailing it to -200. + var ship = new Transform3D(Basis.Identity, new Vector3(0f, 0f, 0f)); + var zone = new OceanCalmZone(Vector2.Zero, new Vector2(0f, 1f)); + zone.Add(new Aabb(new Vector3(-10f, -5f, -60f), new Vector3(20f, 10f, 120f)), ship); + zone.Add(new Aabb(new Vector3(-25f, 0.2f, -200f), new Vector3(50f, 0.1f, 140f)), ship); + Assert.Equal(130f, zone.HalfExtents.X, Eps); + Assert.Equal(25f, zone.HalfExtents.Y, Eps); + Assert.Equal(-70f, zone.Center.Y, Eps); + Assert.Equal(0f, zone.Distance(new Vector2(24f, -199f)), Eps); + } + + [Fact] + public void AHeadingWithNoLengthFallsBackToX() + { + var zone = new OceanCalmZone(Vector2.Zero, Vector2.Zero); + Assert.Equal(new Vector2(1f, 0f), zone.Axis); + } + + [Theory] + [InlineData(0f, 0f)] + [InlineData(6f, 0f)] + [InlineData(31f, 0.5f)] + [InlineData(56f, 1f)] + [InlineData(500f, 1f)] + [InlineData(18.5f, 0.15625f)] + public void TheFadeIsTheShadersSmoothstep(float distance, float expected) => + Assert.Equal(expected, OceanCalmZone.Calm(distance, 6f, 50f), Eps); +} diff --git a/CSVM.Tests/OceanMaskRasterTests.cs b/CSVM.Tests/OceanMaskRasterTests.cs new file mode 100644 index 000000000..80ffd06ce --- /dev/null +++ b/CSVM.Tests/OceanMaskRasterTests.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using CSVM.Effects; +using Godot; +using Xunit; +using Kind = CSVM.Effects.OceanMaskRaster.Kind; +using Tri = CSVM.Effects.OceanMaskRaster.Tri; + +namespace CSVM.Tests; + +/// +/// The ocean mask's texels must not depend on how the bake splits its work. Each case runs +/// at several band counts and wants identical bytes. The oracle +/// is a plain whole-image pass over every texel of every triangle's box. It uses the Vector2 and +/// Color operators the scalar code stands for. +/// +public class OceanMaskRasterTests +{ + private const float Cell = OceanMaskRaster.Cell; + + private static readonly Color Mean = new(0.2f, 0.35f, 0.4f); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void EveryBandCountMatchesTheWholeImagePass(int seed) + { + var tris = Scene(new Random(seed)); + var (w, h, mask, tint) = Reference(tris, Mean); + foreach (int bands in new[] { 1, 5, 12 }) + { + var r = OceanMaskRaster.Run(tris.ToArray(), Mean, bands); + Assert.Equal(w, r.Width); + Assert.Equal(h, r.Height); + Assert.True(mask.AsSpan().SequenceEqual(r.Mask), $"mask differs at {bands} bands"); + Assert.True(tint.AsSpan().SequenceEqual(r.Tint), $"tint differs at {bands} bands"); + } + } + + // Enough rows for a dozen bands, so the distance halo and the band edges are both crossed. + [Fact] + public void TheSceneIsTallEnoughToSplit() + { + var r = OceanMaskRaster.Run(Scene(new Random(1)).ToArray(), Mean, 12); + Assert.Equal(12, r.Bands); + } + + // Every float a tint channel can hold, sampled, plus the ones whose product lands on a half. + [Fact] + public void TheChannelRoundsAsMathRoundDoes() + { + static byte Expected(float v) => (byte)Math.Clamp(Math.Round(v * 255f), 0, 255); + for (float v = -0.01f; v < 1.01f; v = BitConverter.Int32BitsToSingle(BitConverter.SingleToInt32Bits(v) + (v < 0f ? -997 : 997))) + { + if (v < 0f && v > -1e-30f) + v = 0f; + Assert.Equal(Expected(v), OceanMaskRaster.Channel(v)); + } + for (int k = 0; k < 256; k++) + { + int bits = BitConverter.SingleToInt32Bits((k + 0.5f) / 255f); + for (int d = -8; d <= 8; d++) + { + float v = BitConverter.Int32BitsToSingle(bits + d); + Assert.Equal(Expected(v), OceanMaskRaster.Channel(v)); + } + } + } + + // G is the distance from the shore over ShoreReach, saturating past it. It is not a wave + // height: the shader fades the swell and the look over ramps of their own. + [Fact] + public void TheShoreChannelIsTheDistanceFromASolidCorner() + { + var tris = new List(); + AddQuad(tris, -2048f, -2048f, 4096f, Kind.Base, Colors.White); + // A sliver smaller than a texel still marks the texel its corner lands in. + var p = new Vector3(4.1f, 0f, 4.1f); + tris.Add(new Tri(p, p + new Vector3(0.1f, 3f, 0f), p + new Vector3(0f, 3f, 0.1f), Kind.Solid, Colors.White, Colors.White, Colors.White)); + var r = OceanMaskRaster.Run(tris.ToArray(), Mean, 4); + int cx = (int)((p.X - r.Origin.X) / Cell), cz = (int)((p.Z - r.Origin.Y) / Cell); + Assert.Equal(255, r.Mask[2 * ((cz * r.Width) + cx)]); + Assert.Equal(0, r.Mask[(2 * ((cz * r.Width) + cx)) + 1]); + Assert.Equal(128, r.Mask[(2 * ((cz * r.Width) + cx + (int)(OceanMaskRaster.ShoreReach / Cell / 2f))) + 1]); + Assert.Equal(255, r.Mask[(2 * ((cz * r.Width) + cx + 40)) + 1]); + } + + // Coast-tile water beside the sheet takes the sheet's own tint, not the mean. The shader's + // linear filter then cannot lighten the sheet's edge against an opaque coast tile. + [Fact] + public void WaterBesideTheSheetTakesTheSheetsTint() + { + var tris = new List(); + var dark = new Color(0.1f, 0.1f, 0.1f); + AddQuad(tris, -1024f, -1024f, 1024f, Kind.Base, dark); + AddQuad(tris, 0f, -1024f, 1024f, Kind.Edge, Colors.White); + var r = OceanMaskRaster.Run(tris.ToArray(), Mean, 3); + int Tint(float x, float z) => r.Tint[3 * ((((int)((z - r.Origin.Y) / Cell)) * r.Width) + (int)((x - r.Origin.X) / Cell))]; + Assert.Equal(26, Tint(-4f, -500f)); + Assert.Equal(26, Tint(4f, -500f)); + Assert.Equal(51, Tint(500f, -500f)); + } + + // A sheet split over two zone layers, with surf-ring water on the seam. Each texel names the + // group of the sheet it shows, the ring every group, and no band count moves either. + [Fact] + public void EachTexelNamesItsSheetsZoneGroup() + { + var tris = new List(); + AddQuad(tris, -1024f, -1024f, 1024f, Kind.Base, Colors.White); + int east = tris.Count; + AddQuad(tris, 0f, -1024f, 1024f, Kind.Base, Colors.White); + for (int i = east; i < tris.Count; i++) + tris[i] = tris[i] with { Zone = 1 }; + var p = new Vector3(-100f, 0f, -600f); + tris.Add(new Tri(p, p + new Vector3(200f, 0f, 0f), p + new Vector3(0f, 0f, 200f), Kind.Edge, Colors.White, Colors.White, Colors.White)); + var one = OceanMaskRaster.Run(tris.ToArray(), Mean, 1); + byte At(float x, float z) => one.Zones[((int)((z - one.Origin.Y) / Cell) * one.Width) + (int)((x - one.Origin.X) / Cell)]; + Assert.Equal(0, At(-500f, -100f)); + Assert.Equal(1, At(500f, -100f)); + Assert.Equal(OceanMaskRaster.AnyZone, At(-50f, -550f)); + Assert.True(one.Zones.AsSpan().SequenceEqual(OceanMaskRaster.Run(tris.ToArray(), Mean, 7).Zones)); + } + + // A ramp's base triangle marks its texels with the ramp byte, which the hide reads, and the rest + // of the sheet stays 255. Both are sea, and no band count moves either. + [Fact] + public void ARampsTexelsCarryTheRampByte() + { + var tris = new List(); + AddQuad(tris, -1024f, -1024f, 2048f, Kind.Base, Colors.White); + var p = new Vector3(-200f, 0f, -200f); + tris.Add(new Tri(p, p + new Vector3(400f, 1f, 0f), p + new Vector3(0f, 0f, 400f), Kind.Base, Colors.White, Colors.White, Colors.White, Ramp: true)); + var one = OceanMaskRaster.Run(tris.ToArray(), Mean, 1); + byte At(float x, float z) => one.Mask[2 * (((int)((z - one.Origin.Y) / Cell) * one.Width) + (int)((x - one.Origin.X) / Cell))]; + Assert.Equal(OceanMaskRaster.RampSea, At(-150f, -150f)); + Assert.Equal(255, At(500f, 500f)); + var seven = OceanMaskRaster.Run(tris.ToArray(), Mean, 7); + Assert.True(one.Mask.AsSpan().SequenceEqual(seven.Mask)); + Assert.True(one.Lift.AsSpan().SequenceEqual(seven.Lift)); + } + + // The ocean rises with a ramp's plane, past its rim on the side it rises to, and stays at sea + // level away from it. + [Fact] + public void TheLiftFollowsTheRampsPlane() + { + var tris = new List(); + AddQuad(tris, -1024f, -1024f, 2048f, Kind.Base, Colors.White); + var p = new Vector3(-200f, 0f, -200f); + tris.Add(new Tri(p, p + new Vector3(400f, 2f, 0f), p + new Vector3(0f, 0f, 400f), Kind.Base, Colors.White, Colors.White, Colors.White, Ramp: true)); + var r = OceanMaskRaster.Run(tris.ToArray(), Mean, 3); + float Lift(int x, int z) => r.Lift[(z * r.Width) + x] / 255f * OceanMask.RampTop; + float CentreX(int x) => r.Origin.X + ((x + 0.5f) * Cell); + int zRow = (int)((-180f - r.Origin.Y) / Cell); + for (int x = (int)((-150f - r.Origin.X) / Cell); x <= (int)((150f - r.Origin.X) / Cell); x++) + Assert.InRange(Lift(x, zRow), ((CentreX(x) + 200f) / 200f) - 0.02f, ((CentreX(x) + 200f) / 200f) + 0.02f); + Assert.Equal(0, r.Lift[((int)((500f - r.Origin.Y) / Cell) * r.Width) + (int)((500f - r.Origin.X) / Cell)]); + } + + // C1B-like: a sheet of 512 m base quads with baked colours and scattered edge water. The solids + // run from slivers to large faces, some reaching past the sheet. + private static List Scene(Random rng) + { + var tris = new List(); + for (int qz = 0; qz < 12; qz++) + { + for (int qx = 0; qx < 12; qx++) + { + float x = -3072f + (qx * 512f), z = -3072f + (qz * 512f); + var a = new Vector3(x, Jitter(rng), z); + var b = new Vector3(x + 512f, Jitter(rng), z); + var c = new Vector3(x + 512f, Jitter(rng), z + 512f); + var d = new Vector3(x, Jitter(rng), z + 512f); + tris.Add(new Tri(a, b, c, Kind.Base, Rgb(rng), Rgb(rng), Rgb(rng))); + tris.Add(new Tri(a, c, d, Kind.Base, Rgb(rng), Rgb(rng), Rgb(rng))); + } + } + for (int i = 0; i < 300; i++) + tris.Add(Scatter(rng, Kind.Edge, rng.NextSingle() * 60f)); + for (int i = 0; i < 2000; i++) + tris.Add(Scatter(rng, Kind.Solid, rng.Next(4) == 0 ? 0.5f : rng.NextSingle() * 300f)); + // Overlapping base faces: the later one's tint must win in every band. + for (int i = 0; i < 40; i++) + tris.Add(Scatter(rng, Kind.Base, rng.NextSingle() * 400f) with { CA = Rgb(rng), CB = Rgb(rng), CC = Rgb(rng) }); + // Needles and axis-aligned edges. + for (int i = 0; i < 40; i++) + { + var p = Point(rng); + var q = p + new Vector3((rng.NextSingle() * 2000f) - 1000f, 0f, rng.NextSingle() * 3f); + tris.Add(new Tri(p, q, p + new Vector3(0f, 0f, 0.01f + rng.NextSingle()), Kind.Solid, Colors.White, Colors.White, Colors.White)); + tris.Add(new Tri(p, p + new Vector3(300f, 0f, 0f), p + new Vector3(0f, 0f, 300f), Kind.Edge, Colors.White, Colors.White, Colors.White)); + } + return tris; + } + + private static Tri Scatter(Random rng, Kind kind, float size) + { + var p = Point(rng); + Vector3 Off() => new((rng.NextSingle() - 0.5f) * 2f * size, 0f, (rng.NextSingle() - 0.5f) * 2f * size); + return new Tri(p, p + Off(), p + Off(), kind, Colors.White, Colors.White, Colors.White); + } + + private static Vector3 Point(Random rng) => new((rng.NextSingle() * 7000f) - 3500f, 0f, (rng.NextSingle() * 7000f) - 3500f); + + private static float Jitter(Random rng) => (rng.NextSingle() - 0.5f) * 0.4f; + + private static Color Rgb(Random rng) => new(rng.Next(256) / 255f, rng.Next(256) / 255f, rng.Next(256) / 255f); + + private static void AddQuad(List tris, float x, float z, float size, Kind kind, Color c) + { + var a = new Vector3(x, 0f, z); + var b = new Vector3(x + size, 0f, z); + var d = new Vector3(x, 0f, z + size); + var e = new Vector3(x + size, 0f, z + size); + tris.Add(new Tri(a, b, e, kind, c, c, c)); + tris.Add(new Tri(a, e, d, kind, c, c, c)); + } + + // The whole-image pass: every texel of every triangle's box, one thread, one distance pass. + private static (int W, int H, byte[] Mask, byte[] Tint) Reference(List tris, Color mean) + { + float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue; + foreach (var t in tris) + { + if (t.Kind == Kind.Solid) + continue; + foreach (var v in new[] { t.A, t.B, t.C }) + { + minX = Math.Min(minX, v.X); + maxX = Math.Max(maxX, v.X); + minZ = Math.Min(minZ, v.Z); + maxZ = Math.Max(maxZ, v.Z); + } + } + minX = Math.Max(minX, -30000f); + minZ = Math.Max(minZ, -30000f); + maxX = Math.Min(maxX, 30000f); + maxZ = Math.Min(maxZ, 30000f); + int w = Math.Max(1, (int)Math.Ceiling((maxX - minX) / Cell)); + int h = Math.Max(1, (int)Math.Ceiling((maxZ - minZ) / Cell)); + var sea = new byte[w * h]; + var solid = new bool[w * h]; + var tint = new Color[w * h]; + Array.Fill(tint, new Color(-1f, 0f, 0f)); + foreach (var t in tris) + RasterReference(t, minX, minZ, w, h, sea, solid, tint); + var dist = DistanceReference(sea, solid, w, h); + var mask = new byte[w * h * 2]; + var tintBytes = new byte[w * h * 3]; + for (int i = 0; i < w * h; i++) + { + mask[2 * i] = sea[i] != 0 ? (byte)255 : (byte)0; + mask[(2 * i) + 1] = (byte)Math.Round(Math.Min(dist[i], OceanMaskRaster.ShoreReach) / OceanMaskRaster.ShoreReach * 255f); + var c = tint[i].R < 0f ? mean : tint[i]; + tintBytes[3 * i] = (byte)Math.Clamp(Math.Round(c.R * 255f), 0, 255); + tintBytes[(3 * i) + 1] = (byte)Math.Clamp(Math.Round(c.G * 255f), 0, 255); + tintBytes[(3 * i) + 2] = (byte)Math.Clamp(Math.Round(c.B * 255f), 0, 255); + } + // An open texel takes the rounded mean of its tinted neighbours, read from the undilated bytes. + var dilated = (byte[])tintBytes.Clone(); + for (int z = 0; z < h; z++) + { + for (int x = 0; x < w; x++) + { + if (tint[(z * w) + x].R >= 0f) + continue; + var sum = new int[3]; + int n = 0; + for (int nz = Math.Max(0, z - 1); nz <= Math.Min(h - 1, z + 1); nz++) + { + for (int nx = Math.Max(0, x - 1); nx <= Math.Min(w - 1, x + 1); nx++) + { + if (tint[(nz * w) + nx].R < 0f) + continue; + for (int ch = 0; ch < 3; ch++) + sum[ch] += tintBytes[(3 * ((nz * w) + nx)) + ch]; + n++; + } + } + for (int ch = 0; n > 0 && ch < 3; ch++) + dilated[(3 * ((z * w) + x)) + ch] = (byte)Math.Round(sum[ch] / (double)n, MidpointRounding.AwayFromZero); + } + } + return (w, h, mask, dilated); + } + + private static void RasterReference(Tri t, float minX, float minZ, int w, int h, byte[] sea, bool[] solid, Color[] tint) + { + var a = new Vector2(t.A.X, t.A.Z); + var b = new Vector2(t.B.X, t.B.Z); + var c = new Vector2(t.C.X, t.C.Z); + float area = Cross(b - a, c - a); + int x0 = Math.Max(0, (int)Math.Floor((Math.Min(a.X, Math.Min(b.X, c.X)) - minX) / Cell)); + int x1 = Math.Min(w - 1, (int)Math.Ceiling((Math.Max(a.X, Math.Max(b.X, c.X)) - minX) / Cell)); + int z0 = Math.Max(0, (int)Math.Floor((Math.Min(a.Y, Math.Min(b.Y, c.Y)) - minZ) / Cell)); + int z1 = Math.Min(h - 1, (int)Math.Ceiling((Math.Max(a.Y, Math.Max(b.Y, c.Y)) - minZ) / Cell)); + if (t.Kind == Kind.Solid) + { + foreach (var p in new[] { a, b, c }) + Mark((int)((p.X - minX) / Cell), (int)((p.Y - minZ) / Cell)); + } + if (Math.Abs(area) < 1e-6f) + return; + for (int z = z0; z <= z1; z++) + { + for (int x = x0; x <= x1; x++) + { + var p = new Vector2(minX + ((x + 0.5f) * Cell), minZ + ((z + 0.5f) * Cell)); + float w0 = Cross(b - a, p - a) / area; + float w1 = Cross(c - b, p - b) / area; + float w2 = Cross(a - c, p - c) / area; + if (w0 >= -1e-4f && w1 >= -1e-4f && w2 >= -1e-4f) + { + Mark(x, z); + if (t.Kind == Kind.Base) + tint[(z * w) + x] = (t.CA * w1) + (t.CB * w2) + (t.CC * w0); + } + } + } + + void Mark(int x, int z) + { + if (x < 0 || z < 0 || x >= w || z >= h) + return; + int i = (z * w) + x; + switch (t.Kind) + { + case Kind.Base: + if (sea[i] == 0) + sea[i] = 1; + break; + case Kind.Edge: + sea[i] = 2; + break; + default: + solid[i] = true; + break; + } + } + } + + private static float Cross(Vector2 u, Vector2 v) => (u.X * v.Y) - (u.Y * v.X); + + private static float[] DistanceReference(byte[] sea, bool[] solid, int w, int h) + { + var dist = new float[w * h]; + for (int i = 0; i < dist.Length; i++) + dist[i] = sea[i] == 1 && !solid[i] ? 1e6f : 0f; + float d1 = Cell, d2 = Cell * 1.4142f; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + int i = (y * w) + x; + float d = dist[i]; + if (d == 0f) + continue; + if (x > 0) d = Math.Min(d, dist[i - 1] + d1); + if (y > 0) d = Math.Min(d, dist[i - w] + d1); + if (x > 0 && y > 0) d = Math.Min(d, dist[i - w - 1] + d2); + if (x < w - 1 && y > 0) d = Math.Min(d, dist[i - w + 1] + d2); + dist[i] = d; + } + } + for (int y = h - 1; y >= 0; y--) + { + for (int x = w - 1; x >= 0; x--) + { + int i = (y * w) + x; + float d = dist[i]; + if (d == 0f) + continue; + if (x < w - 1) d = Math.Min(d, dist[i + 1] + d1); + if (y < h - 1) d = Math.Min(d, dist[i + w] + d1); + if (x < w - 1 && y < h - 1) d = Math.Min(d, dist[i + w + 1] + d2); + if (x > 0 && y < h - 1) d = Math.Min(d, dist[i + w - 1] + d2); + dist[i] = d; + } + } + return dist; + } +} diff --git a/CSVM.Tests/OceanMoversTests.cs b/CSVM.Tests/OceanMoversTests.cs new file mode 100644 index 000000000..810740b26 --- /dev/null +++ b/CSVM.Tests/OceanMoversTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.Linq; +using CSVM.Effects; +using CSVM.Mech3; +using Godot; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The rule that finds the boats an animation carries across the sea (). +/// These hold which played events move a node and when a triangle reaches the waterline. They also +/// hold the sea test under a hull's origin, and which hulls keep a zone when slots run out. +/// +public class OceanMoversTests +{ + [Fact] + public void ATranslatingMotionMovesItsTarget() + { + Assert.True(OceanMovers.Moves(Event("ObjectMotionFromTo", ("name", "yacht1"), ("translate", new Dictionary())))); + Assert.True(OceanMovers.Moves(Event("ObjectMotionFromTo", ("name", "yacht1"), ("translate_delta", new Dictionary())))); + } + + [Fact] + public void ARotationOnlyMotionLeavesItsTargetInPlace() + { + var ev = Event("ObjectMotionFromTo", ("name", "sinker"), ("translate", null), ("rotate", new Dictionary())); + Assert.False(OceanMovers.Moves(ev)); + } + + [Fact] + public void AnSiScriptAndAPlayedTranslateStateMoveTheirTargets() + { + Assert.True(OceanMovers.Moves(Event("ObjectMotionSiScript", ("name", "tugandbarge01"), ("index", 0)))); + Assert.True(OceanMovers.Moves(Event("ObjectTranslateState", ("node", "barracuda")))); + } + + [Fact] + public void BallisticDebrisSpinsAndRotationsAreNotMovers() + { + var debris = Event("ObjectMotion", ("node", "part1"), ("translation_range", new Dictionary())); + var spin = Event("ObjectMotion", ("node", "prop"), ("xyz_rotation", new Dictionary())); + Assert.False(OceanMovers.Moves(debris)); + Assert.False(OceanMovers.Moves(spin)); + Assert.False(OceanMovers.Moves(Event("ObjectRotateState", ("node", "turret")))); + } + + [Fact] + public void TheAllNamesFormPlaysEachRecordAsItsOwnSiScript() + { + var motions = new List + { + new Dictionary { ["name"] = "walker1", ["index"] = 2 }, + new Dictionary { ["node_path"] = new List { "ship", "lifeboat" } }, + }; + var ev = Event(AnimDefinition.AllNamesKind, ("motions", motions)); + ev.StartOffset = "Sequence"; + ev.StartTime = 1.5f; + + var records = ev.AllNamesMotions().ToList(); + + Assert.True(OceanMovers.Moves(ev)); + Assert.All(records, r => Assert.Equal(("ObjectMotionSiScript", "Sequence", 1.5f), (r.Kind, r.StartOffset, r.StartTime))); + Assert.Equal("walker1", records[0].Data.Str("name")); + Assert.Equal(new object?[] { "ship", "lifeboat" }, records[1].Data.List("node_path")); + Assert.Empty(Event("ObjectMotionSiScript", ("name", "walker1")).AllNamesMotions()); + } + + [Fact] + public void ADefinitionHandsBackItsPlayedMovingEventsInOrderAndIgnoresItsResetState() + { + var def = new AnimDefinition(); + var seq = new AnimSequence(); + var sail = Event("ObjectMotionFromTo", ("name", "sailboat3"), ("translate", new Dictionary())); + var emitter = Event("ObjectActiveState", ("node", "sail_emit1"), ("state", true)); + var again = Event("ObjectMotionFromTo", ("name", "SAILBOAT3"), ("translate", new Dictionary())); + seq.Events.AddRange(new[] { sail, emitter, again }); + def.Sequences.Add(seq); + def.ResetState = new AnimSequence(); + def.ResetState.Events.Add(Event("ObjectTranslateState", ("node", "placed_once"))); + Assert.Equal(new[] { sail, again }, OceanMovers.MovingEvents(def)); + } + + [Theory] + [InlineData(-6f, -2.5f, false)] + [InlineData(-6f, -2f, true)] + [InlineData(-1f, 30f, true)] + [InlineData(2f, 30f, true)] + [InlineData(2.5f, 30f, false)] + public void ATriangleReachesTheWaterlineWithinTwoMetres(float low, float high, bool reaches) => + Assert.Equal(reaches, OceanMovers.ReachesWaterline(new Vector3(0f, low, 0f), new Vector3(10f, high, 0f), new Vector3(0f, high, 10f))); + + [Theory] + [InlineData(2f, 2f, true)] + [InlineData(5f, 5f, true)] + [InlineData(0f, 0f, true)] + [InlineData(6f, 6f, false)] + [InlineData(-1f, 3f, false)] + public void TheSeaTestCoversATrianglesShadowEitherWinding(float x, float z, bool covered) + { + var a = new Vector3(0f, 0f, 0f); + var b = new Vector3(10f, 0.3f, 0f); + var c = new Vector3(0f, -0.2f, 10f); + var p = new Vector2(x, z); + Assert.Equal(covered, OceanMovers.Covers(a, b, c, p)); + Assert.Equal(covered, OceanMovers.Covers(a, c, b, p)); + } + + [Fact] + public void WithMoreHullsThanSlotsTheNearestKeepTheirListingOrder() + { + var hulls = new List + { + new(1000f, 0f, 0f), new(10f, 0f, 0f), new(500f, 0f, 0f), new(20f, 0f, 0f), new(30f, 0f, 0f), + }; + var order = new List(); + OceanMovers.Nearest(hulls, Vector3.Zero, 3, order); + Assert.Equal(new[] { 1, 3, 4 }, order); + OceanMovers.Nearest(hulls, Vector3.Zero, 16, order); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, order); + } + + [Fact] + public void EquidistantHullsKeepTheEarlierListed() + { + var hulls = new List { new(0f, 0f, 50f), new(50f, 0f, 0f), new(0f, 0f, -50f) }; + var order = new List(); + OceanMovers.Nearest(hulls, Vector3.Zero, 2, order); + Assert.Equal(new[] { 0, 1 }, order); + } + + private static AnimEvent Event(string kind, params (string Key, object? Value)[] data) => new() + { + Kind = kind, + Data = new AnimData(data.ToDictionary(d => d.Key, d => d.Value)), + }; +} diff --git a/CSVM.Tests/OceanSeasTests.cs b/CSVM.Tests/OceanSeasTests.cs new file mode 100644 index 000000000..c956e50f3 --- /dev/null +++ b/CSVM.Tests/OceanSeasTests.cs @@ -0,0 +1,206 @@ +using System.IO; +using System.Linq; +using CSVM.Effects; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The shipped per-chapter seas (). A missing entry or field takes the default, +/// and a mistake is a warning rather than a crash. The lab's Save writes one chapter's differing fields +/// and nothing else, in a stable order, so the file round-trips. +/// +[Trait("Tier", "Quick")] +public sealed class OceanSeasTests +{ + // Every sea chapter at the defaults, so the fields a Save writes are the only ones off them. The + // shipped file holds the user's tuned seas, which the Save tests must not depend on. + private const string AllDefaults = + "{\n \"_about\": \"prose\",\n \"C1\": {},\n \"C1B\": {},\n \"C1C\": {},\n \"C2\": {},\n" + + " \"C2B\": {},\n \"C3\": {},\n \"C5\": {}\n}\n"; + + private static readonly string[] FileKeys = { "_about", "C1", "C1B", "C1C", "C2", "C2B", "C3", "C5" }; + + private static string ShippedPath => Path.Combine(TestData.RepoRoot, "CSVM", "data", "ocean_seas.json"); + + /// The shipped file reads clean and names every sea chapter. A clean read means no + /// unknown chapter or field and every value in the lab's range; each sea is as the file writes it. + [Fact] + public void TheShippedFileReadsCleanAndNamesEverySeaChapter() + { + string shipped = File.ReadAllText(ShippedPath); + var seas = OceanSeas.Parse(shipped); + Assert.Empty(seas.Warnings); + var keys = System.Text.Json.Nodes.JsonNode.Parse(shipped)!.AsObject().Select(p => p.Key).Where(k => !k.StartsWith('_')); + Assert.Equal(OceanSeas.Chapters, keys); + Assert.All(OceanSeas.Chapters, c => Assert.Equal(seas.For(c).Written(), seas.For(c))); + Assert.Equal(SeaState.Default, seas.For("C4")); + } + + [Theory] + [InlineData("{}")] + [InlineData("{ \"_about\": \"prose\" }")] + [InlineData("{ \"C1B\": {} }")] + public void AnEmptyFileOrEntryTakesTheDefaults(string json) + { + var seas = OceanSeas.Parse(json); + Assert.Empty(seas.Warnings); + Assert.Equal(SeaState.Default, seas.For("C1B")); + } + + [Fact] + public void AnUnreadableFileWarnsAndTakesTheDefaults() + { + var seas = OceanSeas.Parse("{ not json"); + Assert.Single(seas.Warnings); + Assert.Equal(SeaState.Default, seas.For("C1")); + } + + /// A partial entry sets its own fields and leaves the rest, and the other chapters, alone. + [Fact] + public void APartialEntrySetsOnlyItsFields() + { + var seas = OceanSeas.Parse("{ \"c1b\": { \"height\": 1.4, \"foam_strength\": 0.2 } }"); + Assert.Empty(seas.Warnings); + Assert.Equal(SeaState.Default with { Height = 1.4f, FoamStrength = 0.2f }, seas.For("C1B")); + Assert.Equal(SeaState.Default, seas.For("C1")); + } + + /// An unknown field, an unknown chapter and a value that is not a number are each one + /// warning; the entry's good fields still apply. + [Fact] + public void AMistakeIsAWarningNotACrash() + { + var seas = OceanSeas.Parse("{ \"C2\": { \"hieght\": 2, \"length\": \"long\", \"wind\": 45 }, \"C4\": {}, \"C3\": 5 }"); + Assert.Equal(4, seas.Warnings.Count); + Assert.Contains(seas.Warnings, w => w.Contains("'hieght' is not a sea field")); + Assert.Contains(seas.Warnings, w => w.Contains("'length' is not a number")); + Assert.Contains(seas.Warnings, w => w.Contains("'C4' is not a sea chapter")); + Assert.Contains(seas.Warnings, w => w.Contains("C3 is not an object")); + Assert.Equal(SeaState.Default with { WindDeg = 45f }, seas.For("C2")); + } + + /// A value out of range is held at its edge, and the file is told so. + [Fact] + public void AnOutOfRangeValueIsClampedAndReported() + { + var seas = OceanSeas.Parse("{ \"C5\": { \"length\": 3, \"swell_full\": 500 } }"); + Assert.Equal((2f, 160f), (seas.For("C5").Length, seas.For("C5").SwellFull)); + Assert.Equal(2, seas.Warnings.Count); + } + + /// Save writes the chapter's differing fields alone, in the fields' order. Every other key + /// stays where it was, and the file reads back as the sea it wrote. + [Fact] + public void SaveWritesOnlyTheDifferingFieldsAndRoundTrips() + { + var sea = SeaState.Default with { FoamStrength = 0.2f, Height = 1.4f, Length = 1.5f }; + string written = OceanSeas.WithEntry(AllDefaults, "c1b", sea); + + Assert.Contains("\"C1B\": {\n \"height\": 1.4,\n \"length\": 1.5,\n \"foam_strength\": 0.2\n }", Lf(written)); + var keys = System.Text.Json.Nodes.JsonNode.Parse(written)!.AsObject().Select(p => p.Key).ToArray(); + Assert.Equal(FileKeys, keys); + var back = OceanSeas.Parse(written); + Assert.Empty(back.Warnings); + Assert.Equal(sea, back.For("C1B")); + Assert.Equal(SeaState.Default, back.For("C2")); + } + + /// A Save into the shipped file replaces its one chapter: every other chapter reads back + /// as it was loaded, and every key keeps its place. + [Fact] + public void SaveIntoTheShippedFileLeavesTheOtherChaptersAsLoaded() + { + string shipped = File.ReadAllText(ShippedPath); + var loaded = OceanSeas.Parse(shipped); + var sea = loaded.For("C1B") with { FoamStrength = loaded.For("C1B").FoamStrength == 0.2f ? 0.3f : 0.2f }; + string written = OceanSeas.WithEntry(shipped, "C1B", sea); + + var keys = System.Text.Json.Nodes.JsonNode.Parse(written)!.AsObject().Select(p => p.Key); + Assert.Equal(System.Text.Json.Nodes.JsonNode.Parse(shipped)!.AsObject().Select(p => p.Key), keys); + var back = OceanSeas.Parse(written); + Assert.Empty(back.Warnings); + Assert.Equal(sea.Written(), back.For("C1B")); + Assert.All(OceanSeas.Chapters.Where(c => c != "C1B"), c => Assert.Equal(loaded.For(c), back.For(c))); + } + + /// Saving each chapter's loaded sea over the shipped file writes the shipped file again. The + /// defaults saved over an all-defaults file write it again too, so a Save changes only what the lab changed. + [Fact] + public void SavingWhatWasLoadedLeavesTheFileAsItIs() + { + string shipped = File.ReadAllText(ShippedPath); + var loaded = OceanSeas.Parse(shipped); + Assert.All(OceanSeas.Chapters, c => Assert.Equal(Lf(shipped), Lf(OceanSeas.WithEntry(shipped, c, loaded.For(c))))); + Assert.Equal(AllDefaults, Lf(OceanSeas.WithEntry(AllDefaults, "C3", SeaState.Default))); + } + + /// Save on disk: a file it creates, then a second chapter added beside the first. + [Fact] + public void SaveOnDiskCreatesThenUpdates() + { + string path = Path.Combine(TestData.TempDir(), "ocean_seas.json"); + Assert.Equal(1, OceanSeas.Save(path, "C2", SeaState.Default with { WindDeg = 90f })); + Assert.Equal(2, OceanSeas.Save(path, "C3", SeaState.Default with { Length = 1.2f, FoamCover = 0.2f })); + var seas = OceanSeas.Parse(File.ReadAllText(path)); + Assert.Equal(90f, seas.For("C2").WindDeg); + Assert.Equal((1.2f, 0.2f), (seas.For("C3").Length, seas.For("C3").FoamCover)); + } + + /// A comment added by hand is read past on Save as on load, so every other chapter and the + /// prose key survive the write. + [Fact] + public void SaveReadsPastAComment() + { + string commented = "// tuned by hand\n" + AllDefaults.Replace("\"C2\":", "/* the next sea */ \"C2\":"); + Assert.Empty(OceanSeas.Parse(commented).Warnings); + string written = OceanSeas.WithEntry(commented, "C5", SeaState.Default with { WindDeg = 90f }); + + var keys = System.Text.Json.Nodes.JsonNode.Parse(written)!.AsObject().Select(p => p.Key).ToArray(); + Assert.Equal(FileKeys, keys); + Assert.Equal(AllDefaults, Lf(OceanSeas.WithEntry(written, "C5", SeaState.Default))); + } + + /// A file Save cannot read is left as it was rather than replaced by the one chapter saved. + [Theory] + [InlineData("{ \"C1\": {}, ")] + [InlineData("[ 1, 2 ]")] + public void SaveRefusesAFileItCannotRead(string text) + { + string path = Path.Combine(TestData.TempDir(), "ocean_seas.json"); + File.WriteAllText(path, text); + Assert.Throws(() => OceanSeas.Save(path, "C2", SeaState.Default with { WindDeg = 90f })); + Assert.Equal(text, File.ReadAllText(path)); + } + + /// A height clamped to the fold limit is written at a precision that stays inside it. The + /// saved file then loads with no clamp warning, as the sea the lab held. + [Theory] + [InlineData(0.55f)] + [InlineData(0.6f)] + [InlineData(0.7f)] + [InlineData(0.9f)] + [InlineData(0.77f)] + public void AHeightAtTheFoldLimitRoundTripsWithoutAWarning(float sharpness) + { + var sea = (SeaState.Default with { Sharpness = sharpness, Height = 2f }).Clamped(); + Assert.Equal(SeaState.FoldLimit / sharpness, sea.Height); + var back = OceanSeas.Parse(OceanSeas.WithEntry(null, "C1", sea)); + Assert.Empty(back.Warnings); + Assert.Equal(sea.Written(), back.For("C1")); + Assert.True(back.For("C1").Height * sharpness <= SeaState.FoldLimit); + Assert.Equal(sea.Height, back.For("C1").Height, 3); + } + + /// A coast ramp held at its narrowest reads back without a clamp warning as well. + [Fact] + public void ARampAtItsNarrowestRoundTripsWithoutAWarning() + { + var sea = (SeaState.Default with { SwellFull = 100.12345f, SwellFrom = 150f, LookFull = 70.00007f, LookFrom = 80f }).Clamped(); + var back = OceanSeas.Parse(OceanSeas.WithEntry(null, "C3", sea)); + Assert.Empty(back.Warnings); + Assert.Equal(sea.Written(), back.For("C3")); + } + + private static string Lf(string text) => text.Replace("\r\n", "\n"); +} diff --git a/CSVM.Tests/OceanShaderTests.cs b/CSVM.Tests/OceanShaderTests.cs new file mode 100644 index 000000000..4858be9f6 --- /dev/null +++ b/CSVM.Tests/OceanShaderTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using CSVM.Effects; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The wave ocean's generated shader text (). At the default sea it is the +/// text the ocean's constants wrote, byte for byte, so no golden moves until a chapter's sea is +/// saved. Every rate a sea can move stays a whole number of cycles per csky_time wrap. +/// +[Trait("Tier", "Quick")] +public sealed class OceanShaderTests +{ + private const double Wrap = 3600.0; + + /// The default sea's text with LF line endings, at each priority level and zone gate a mask + /// can hand the generator. Each hash is the text the constants generated. + [Theory] + [InlineData(-11, false, "F7FC2225F8D50735EB570F6E9414CFEDDB6CEF8F699CC51310A0441C184184DB")] + [InlineData(-11, true, "FD3582BF99925E40AFB66C05FC9A08A439D8FC3F980451B116E8982D72847B9D")] + [InlineData(-2, false, "6AD1E103B8E43789496B663D203744141FBF069A2A538BE93BAEC29FAE9C7AD1")] + [InlineData(-2, true, "1719C5863FF12CBC40BED3F09B1DC7FDB36AF37BE9E93BDA10A36C307A4C2E3B")] + [InlineData(-1, false, "6CC67D48388CDB1E608A1D3C0F2DB9C85A9CBB30DF0BDFC199C1676D7BF48BA4")] + [InlineData(-1, true, "1315ECA59AA67C0CB82E52F5616E7AC359556A2C2C94662A14749C13370BCD71")] + [InlineData(0, false, "A792F2BB813170229C4AEEC33EA1C9EF4761758DFFF170F86E55DE6E4F255477")] + [InlineData(0, true, "3EF00F93C0D4B588077A48FDF5029DE868515AE02B0F69650BD4284F2885DEA3")] + [InlineData(1, false, "CE51836C0EE62C91DF46F22484FA68667666D8E721C364A686CC033B35C0FFA5")] + [InlineData(1, true, "55D546009592D436DE1ED3F0D1F73D8C494772D5D109DD9011AEE65AD6934868")] + [InlineData(2, false, "5FA54A893AFFDE5A5FC77F4683C175EA419AD67B089F0CAC3C2BE78087956277")] + [InlineData(2, true, "1BB1D4D600C1BCD05E2E85530004A99F300FED931BAE0D35ADC51AF1F7FA02D0")] + [InlineData(3, false, "B3A1126C8E55A4EA27C2873EE8767720949E3D34B9FE484ABD8D71A5293C6B53")] + [InlineData(3, true, "5ADBF6F8A7EEE43120FD84F001BEC81ACF1D8CDA0EDBFA1E07BAF7C6C2768BFB")] + public void TheDefaultSeaWritesTheConstantsText(int level, bool zoned, string sha256) + { + string text = Lf(OceanShader.Code(SeaState.Default, level, zoned)); + Assert.Equal(sha256, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))); + } + + /// The same rule as a readable diff: the first line that differs from the fixture names itself. + [Fact] + public void TheDefaultSeaMatchesTheFixtureLineByLine() + { + string[] want = Lf(File.ReadAllText(TestData.Fixture("ocean", "default_l0_zoned.gdshader.txt"))).Split('\n'); + string[] got = Lf(OceanShader.Code(SeaState.Default, 0, true)).Split('\n'); + for (int i = 0; i < Math.Min(want.Length, got.Length); i++) + Assert.True(want[i] == got[i], $"line {i + 1} differs:\n want {want[i]}\n got {got[i]}"); + Assert.Equal(want.Length, got.Length); + } + + /// Height, foam strength and roughness are uniforms the ocean sets, so they leave the + /// text alone and a lab drag on them compiles nothing. + [Fact] + public void TheUniformFieldsLeaveTheTextAlone() + { + var sea = SeaState.Default with { Height = 1.4f, FoamStrength = 0.3f, RoughNear = 0.5f, RoughFar = 0.6f }; + Assert.Equal(OceanShader.Code(SeaState.Default, 0, false), OceanShader.Code(sea, 0, false)); + } + + /// Every other field reaches the text. + [Theory] + [InlineData("length", 1.5f)] + [InlineData("wind", 80f)] + [InlineData("sharpness", 0.4f)] + [InlineData("bend_m", 90f)] + [InlineData("bend_rad", 5f)] + [InlineData("detail_slope", 0.12f)] + [InlineData("detail_drift", 2f)] + [InlineData("foam_threshold", 1f)] + [InlineData("foam_patch", 2f)] + [InlineData("foam_cover", 0.45f)] + [InlineData("swell_from", 40f)] + [InlineData("swell_full", 120f)] + [InlineData("look_from", 20f)] + [InlineData("look_full", 90f)] + [InlineData("tint_b", 1.2f)] + public void AShaderFieldChangesTheText(string key, float value) + { + var sea = SeaState.Find(key)!.With(SeaState.Default, value); + Assert.NotEqual(OceanShader.Code(SeaState.Default, 0, false), OceanShader.Code(sea, 0, false)); + } + + /// A tint multiplies the open sea's colour alone; an untinted sea writes no tint term. + [Fact] + public void ATintMultipliesTheOpenSeaOnly() + { + string tinted = OceanShader.Code(SeaState.Default with { TintR = 0.9f, TintG = 1f, TintB = 1.1f }, 0, false); + Assert.Contains("vec3 tex_mean = textureLod(albedo_tex, vec2(0.5), 16.0).rgb * vec3(0.9, 1.0, 1.1);", tinted); + Assert.Contains("vec3 tex_mean = textureLod(albedo_tex, vec2(0.5), 16.0).rgb;", OceanShader.Code(SeaState.Default, 0, false)); + } + + /// A length scale moves every wave's omega, which is rounded again so the csky_time + /// wrap lands on a whole number of cycles of every wave. + [Theory] + [InlineData(1f)] + [InlineData(0.5f)] + [InlineData(1.37f)] + [InlineData(2f)] + public void EverySwellWaveRunsWholeCyclesPerWrap(float length) + { + string text = OceanShader.Code(SeaState.Default with { Length = length }, 0, false); + var omegas = Vec2s(text, "SWELL_QW").Select(v => v.Y).ToArray(); + Assert.Equal(8, omegas.Length); + foreach (double omega in omegas) + AssertWhole(omega * Wrap / (2 * Math.PI), $"swell omega {omega} at length x{length}"); + var lengths = OceanShader.SwellLengths(SeaState.Default with { Length = length }).ToArray(); + Assert.Equal(152f * length, lengths[0], 3); + } + + /// A detail drift stays a whole number of noise periods per wrap at any scale. A drift of + /// zero holds the detail still rather than flooring at one period. + [Theory] + [InlineData(1f)] + [InlineData(0.35f)] + [InlineData(2.6f)] + [InlineData(0f)] + public void EveryDetailLayerDriftsWholePeriodsPerWrap(float drift) + { + string text = OceanShader.Code(SeaState.Default with { DetailDrift = drift }, 0, false); + var layers = Vec4s(text, "DETAIL_L"); + Assert.Equal(5, layers.Length); + foreach (var l in layers) + { + AssertWhole(l.W * Wrap / 1024.0, $"detail drift {l.W} cells/s at x{drift}"); + if (drift == 0f) + Assert.Equal(0.0, l.W); + else + Assert.True(l.W > 0.0, $"a moving drift never rounds to a stop ({l.W})"); + } + } + + /// Each foam patch octave drifts a whole period of its own cells per wrap at any patch size. + [Theory] + [InlineData(1f)] + [InlineData(0.6f)] + [InlineData(3.3f)] + public void EveryFoamPatchOctaveDriftsWholePeriodsPerWrap(float patch) + { + string text = OceanShader.Code(SeaState.Default with { FoamPatch = patch }, 0, false); + var terms = Regex.Matches(text, @"foam_noise\(pw \* ([0-9.]+) - vec2\(csky_time \* \(([0-9.]+) / 3600\.0\), 0\.0\), ([0-9.]+)\)"); + Assert.Equal(2, terms.Count); + foreach (Match m in terms) + { + double periods = double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture); + AssertWhole(periods, $"patch periods per wrap at x{patch}"); + Assert.Equal(periods, double.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture)); + } + } + + /// The rounding helpers alone: at least one cycle for any wave, none only for a held drift. + [Fact] + public void TheRoundingHelpersHoldTheirFloors() + { + Assert.Equal(1f, OceanShader.SwellCyclesPerWrap(1e9f)); + Assert.Equal(1.0, OceanShader.DetailTurnsPerWrap(6f, 0.01f)); + Assert.Equal(0.0, OceanShader.DetailTurnsPerWrap(6f, 0f)); + Assert.Equal(1f, OceanShader.PatchPeriodsPerWrap(1e9f, 1.5f)); + } + + /// The shader reads csky_time, never Godot's TIME, at any sea: a network peer and a + /// fixed-step capture share the clock. + [Fact] + public void TheShaderReadsTheSessionClockOnly() + { + var rough = SeaState.Default with { Height = 2f, Length = 1.5f, DetailDrift = 3f, FoamPatch = 0.25f }; + foreach (var sea in new[] { SeaState.Default, rough }) + { + string text = OceanShader.Code(sea, 0, true); + Assert.DoesNotMatch(@"\bTIME\b", text); + Assert.Contains("csky_time", text); + } + } + + private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static void AssertWhole(double value, string what) => + Assert.True(Math.Abs(value - Math.Round(value)) < 0.01, $"{what}: {value} per wrap is not whole"); + + private static (double X, double Y)[] Vec2s(string text, string name) => + ConstArray(text, name, "vec2").Select(p => (p[0], p[1])).ToArray(); + + private static (double X, double Y, double Z, double W)[] Vec4s(string text, string name) => + ConstArray(text, name, "vec4").Select(p => (p[0], p[1], p[2], p[3])).ToArray(); + + // The components of a const array the generator wrote, one row per element. + private static double[][] ConstArray(string text, string name, string type) + { + var line = Regex.Match(text, $@"const {type} {name}\[\d+\] = {type}\[\d+\]\((.*)\);"); + Assert.True(line.Success, $"{name} is in the text"); + return Regex.Matches(line.Groups[1].Value, $@"{type}\(([^)]*)\)") + .Select(m => m.Groups[1].Value.Split(',').Select(s => double.Parse(s.Trim(), CultureInfo.InvariantCulture)).ToArray()) + .ToArray(); + } +} diff --git a/CSVM.Tests/SeaStateTests.cs b/CSVM.Tests/SeaStateTests.cs new file mode 100644 index 000000000..f402be054 --- /dev/null +++ b/CSVM.Tests/SeaStateTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Linq; +using CSVM.Effects; +using Xunit; + +namespace CSVM.Tests; + +/// +/// One chapter's sea (). Its defaults are the ocean's tune, and every value is +/// held in its range with sharpness times height at the fold limit. The --debug-ocean grammar keeps only what +/// it can apply. +/// +[Trait("Tier", "Quick")] +public sealed class SeaStateTests +{ + [Fact] + public void TheDefaultsAreTheOceansTune() + { + var d = SeaState.Default; + Assert.Equal((1f, 1f, 20f, 0.55f), (d.Height, d.Length, d.WindDeg, d.Sharpness)); + Assert.Equal((60f, 14f, 0.08f, 1f), (d.BendMetres, d.BendRadians, d.DetailSlope, d.DetailDrift)); + Assert.Equal((1.3f, 0.12f, 1f, 0.3f), (d.FoamThreshold, d.FoamStrength, d.FoamPatch, d.FoamCover)); + Assert.Equal((24f, 160f, 12f, 64f), (d.SwellFrom, d.SwellFull, d.LookFrom, d.LookFull)); + Assert.Equal((0.2f, 0.3f), (d.RoughNear, d.RoughFar)); + Assert.False(d.Tinted); + Assert.Equal(d, d.Clamped()); + Assert.Equal("defaults", d.Describe()); + } + + /// Every field has a unique key, a range holding its default, and a group the lab shows. + [Fact] + public void EveryFieldIsNamedOnceAndHoldsItsDefault() + { + Assert.Equal(SeaState.Fields.Count, SeaState.Fields.Select(f => f.Key).Distinct().Count()); + Assert.All(SeaState.Fields, f => Assert.InRange(f.Default, f.Min, f.Max)); + Assert.Equal(new[] { "Swell", "Bending + detail", "Foam", "Coast + colour" }, + SeaState.Fields.Select(f => f.Group).Distinct()); + } + + [Theory] + [InlineData("length", 9f, 2f)] + [InlineData("length", 0.1f, 0.5f)] + [InlineData("wind", -10f, 0f)] + [InlineData("swell_full", 400f, 160f)] + [InlineData("look_from", 1f, 8f)] + [InlineData("foam_cover", 0f, 0.1f)] + [InlineData("sharpness", float.NaN, 0.55f)] + public void AValueOutOfRangeIsHeldAtItsEdge(string key, float value, float held) + { + var field = SeaState.Find(key)!; + Assert.Equal(held, field.Get(field.With(SeaState.Default, value).Clamped())); + } + + /// Sharpness times height is held at the fold limit by lowering the height, so a crest at + /// the base group gain never loops over itself. Length does not enter, since Q k A does not depend on it. + [Fact] + public void TheSwellIsHeldBelowFolding() + { + var steep = (SeaState.Default with { Height = 2f, Sharpness = 0.8f }).Clamped(); + Assert.Equal(0.8f, steep.Sharpness); + Assert.Equal(1.25f, steep.Height, 4); + var gentle = (SeaState.Default with { Height = 1.4f, Length = 1.5f }).Clamped(); + Assert.Equal(1.4f, gentle.Height); + Assert.True(steep.Height * steep.Sharpness <= SeaState.FoldLimit + 1e-6f); + } + + /// A coast ramp keeps its edges at least RampGap apart, the full edge winning. + [Fact] + public void ACoastRampKeepsItsWidth() + { + var sea = (SeaState.Default with { SwellFrom = 150f, SwellFull = 100f, LookFrom = 70f, LookFull = 30f }).Clamped(); + Assert.Equal((96f, 100f), (sea.SwellFrom, sea.SwellFull)); + Assert.Equal((26f, 30f), (sea.LookFrom, sea.LookFull)); + } + + [Fact] + public void TheOverrideGrammarKeepsWhatItCanApply() + { + var rejected = new List(); + string kept = SeaState.FilterOverrides(" height:1.4 ,open,bogus:2,length:x,LENGTH:1.5,wind", rejected); + Assert.Equal("height:1.4,open,LENGTH:1.5", kept); + Assert.Equal(new[] { "bogus:2", "length:x", "wind" }, rejected); + Assert.True(SeaState.OverridesOpen(kept)); + Assert.False(SeaState.OverridesOpen("height:1")); + Assert.False(SeaState.OverridesOpen(null)); + } + + /// Overrides apply left to right and come out clamped. + [Fact] + public void OverridesApplyInOrderAndClamp() + { + var sea = SeaState.Default.WithOverrides("height:1.2,height:1.4,length:5,open"); + Assert.Equal(1.4f, sea.Height); + Assert.Equal(2f, sea.Length); + Assert.Equal("height:1.40,length:2.00", sea.Describe()); + Assert.Equal(SeaState.Default, SeaState.Default.WithOverrides(null)); + } +} diff --git a/CSVM.Tests/SessionSpecParserTests.cs b/CSVM.Tests/SessionSpecParserTests.cs index 65da26a4c..de727fe93 100644 --- a/CSVM.Tests/SessionSpecParserTests.cs +++ b/CSVM.Tests/SessionSpecParserTests.cs @@ -515,10 +515,23 @@ public void EnhancedPassDoorsAccumulate() Assert.Equal(EnhancedPasses.Glow, SessionSpec.Parse(new[] { "--no-glow" }).SkippedPasses); Assert.Equal( EnhancedPasses.SoftShadows, SessionSpec.Parse(new[] { "--no-soft-shadows" }).SkippedPasses); + Assert.Equal(EnhancedPasses.Ocean, SessionSpec.Parse(new[] { "--no-ocean" }).SkippedPasses); - var all = SessionSpec.Parse(new[] { "--no-ssao", "--no-ssr", "--no-glow", "--no-soft-shadows" }); + var all = SessionSpec.Parse(new[] { "--no-ssao", "--no-ssr", "--no-glow", "--no-soft-shadows", "--no-ocean" }); Assert.Equal( - EnhancedPasses.Ssao | EnhancedPasses.Ssr | EnhancedPasses.Glow | EnhancedPasses.SoftShadows, + EnhancedPasses.Ssao | EnhancedPasses.Ssr | EnhancedPasses.Glow | EnhancedPasses.SoftShadows + | EnhancedPasses.Ocean, all.SkippedPasses); } + + /// --dump-ocean-mask= carries its path and leaves the run's mode alone, since it + /// writes the mask beside an ordinary session rather than ending it. + [Fact] + public void OceanMaskDumpCarriesItsPath() + { + Assert.Equal("", SessionSpec.Parse(new[] { "--fly" }).OceanMaskPath); + var spec = SessionSpec.Parse(new[] { "--fly", "--chapter=C1B", "--dump-ocean-mask=mask.png" }); + Assert.Equal("mask.png", spec.OceanMaskPath); + Assert.True(spec.Fly); + } } diff --git a/CSVM.Tests/SessionSpecTests.cs b/CSVM.Tests/SessionSpecTests.cs index 192de55ce..5248aecf8 100644 --- a/CSVM.Tests/SessionSpecTests.cs +++ b/CSVM.Tests/SessionSpecTests.cs @@ -838,6 +838,20 @@ public void TheDamageScriptGrammarKeepsOnlyItsSteps(string spec, string kept, st Assert.Equal(rejected, string.Join(",", rejects)); } + /// The ocean lab lives in --freecam alone, so its twin is dropped everywhere else, + /// and its value keeps only what the lab can apply. + [Fact] + public void TheOceanLabTwinIsAFreecamFlag() + { + var s = S("--freecam", "--debug-ocean=height:1.4,bogus,open"); + Assert.Equal("height:1.4,open", s.DebugOcean); + Assert.Contains(s.Warnings, w => w.Category == "ui" && w.Message.Contains("'bogus'")); + Assert.Equal("", S("--freecam", "--debug-ocean").DebugOcean); + Assert.Null(S("--fly", "--debug-ocean=height:1.4").DebugOcean); + Assert.Null(S("--anim-lab", "--debug-ocean=height:1.4").DebugOcean); + Assert.Null(S("--viewer", "--debug-ocean=height:1.4").DebugOcean); + } + // ---- Placement ----------------------------------------------------------------------------- [Fact] @@ -1079,6 +1093,19 @@ public void PathFlagsAreOverrideValuesOnly() Assert.Equal("/somewhere", s.DataRoot); } + /// The synthetic tree is opt-in only, and an extraction never lands in it. Beside + /// `--extract` the switch is dropped with a note. + [Fact] + public void SyntheticDataIsOptInAndNeverAppliesToAnExtraction() + { + Assert.False(S("--run-tests").SyntheticData); + Assert.True(S("--run-tests", "--synthetic-data").SyntheticData); + + var extract = S("--extract=/install", "--synthetic-data"); + Assert.False(extract.SyntheticData); + Assert.Contains(extract.Warnings, n => n.Message.Contains("--synthetic-data")); + } + /// `--ia=` is a path VALUE only, loading the file into an `InstantActionDef` is the /// runtime's job, which is what keeps this type free of file /// I/O. diff --git a/CSVM.Tests/SpyglassTests.cs b/CSVM.Tests/SpyglassTests.cs index af4a9ef0d..04c17007d 100644 --- a/CSVM.Tests/SpyglassTests.cs +++ b/CSVM.Tests/SpyglassTests.cs @@ -231,8 +231,10 @@ public void TheSunLayersAreTheirOwnAndOnlyTheDiscDrawsTheCopy() { Assert.Equal(0u, band & SplitScreen.SunLayer); Assert.Equal(0u, band & SplitScreen.SpyglassSunLayer); + Assert.Equal(0u, band & SplitScreen.FlatSeaLayer); } Assert.NotEqual(SplitScreen.SunLayer, SplitScreen.SpyglassSunLayer); + Assert.Equal(0u, SplitScreen.FlatSeaLayer & (SplitScreen.SunLayer | SplitScreen.SpyglassSunLayer)); for (int i = 0; i < SplitScreen.MaxPlayers; i++) { @@ -243,12 +245,16 @@ public void TheSunLayersAreTheirOwnAndOnlyTheDiscDrawsTheCopy() Assert.Equal(0u, pane & SplitScreen.SpyglassSunLayer); Assert.Equal(0u, disc & SplitScreen.SunLayer); Assert.NotEqual(0u, disc & SplitScreen.SpyglassSunLayer); + // The flat-sea marker: the disc carries it, the pane does not. + Assert.Equal(0u, pane & SplitScreen.FlatSeaLayer); + Assert.NotEqual(0u, disc & SplitScreen.FlatSeaLayer); // Nothing else moves: the world, the zone gate and every other airframe stay as the pane has them. - Assert.Equal(pane & ~own & ~SplitScreen.SunLayer, disc & ~SplitScreen.SpyglassSunLayer); + Assert.Equal(pane & ~own & ~SplitScreen.SunLayer, disc & ~SplitScreen.SpyglassSunLayer & ~SplitScreen.FlatSeaLayer); } // The single-player camera, whose mask the launcher's fresh camera starts at all 20 layers. Assert.Equal(0u, SplitScreen.PaneCullMask(0xFFFFF) & SplitScreen.SpyglassSunLayer); + Assert.Equal(0u, SplitScreen.PaneCullMask(0xFFFFF) & SplitScreen.FlatSeaLayer); Assert.NotEqual(0u, SplitScreen.PaneCullMask(0xFFFFF) & SplitScreen.SunLayer); } diff --git a/CSVM.Tests/SuiteCatalogTests.cs b/CSVM.Tests/SuiteCatalogTests.cs index 5f049aa24..4343af451 100644 --- a/CSVM.Tests/SuiteCatalogTests.cs +++ b/CSVM.Tests/SuiteCatalogTests.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; using CSVM.Testing; using Xunit; @@ -57,6 +59,62 @@ public void The_quick_tier_is_a_registered_subset() Assert.Null(SuiteCatalog.Tier("nosuchtier")); } + [Fact] + public void The_ci_tier_is_a_sorted_registered_subset_where_a_skip_fails() + { + var names = Names.ToHashSet(); + + Assert.NotEmpty(SuiteCatalog.CiTier); + Assert.All(SuiteCatalog.CiTier, name => Assert.Contains(name, names)); + Assert.Equal(SuiteCatalog.CiTier.OrderBy(n => n, StringComparer.Ordinal).Distinct().ToArray(), + SuiteCatalog.CiTier.ToArray()); + // Each needs an environment CI lacks: an IPv6 loopback, or an OS shell to open a folder. + Assert.DoesNotContain("enet-dual-stack", SuiteCatalog.CiTier); + Assert.DoesNotContain("enet-stable-ipv6-reply", SuiteCatalog.CiTier); + Assert.DoesNotContain("build-stamp-focus", SuiteCatalog.CiTier); + + Assert.True(SuiteCatalog.Tier("ci")!.SkipFails); + Assert.Same(SuiteCatalog.CiTier, SuiteCatalog.Tier("CI")!.Suites); + Assert.False(SuiteCatalog.Tier("quick")!.SkipFails); + } + + [Fact] + public void The_headless_limits_name_registered_suites_off_the_ci_tier() + { + string path = Path.Combine(TestData.RepoRoot, "analysis", "headless-limits.json"); + using var limits = JsonDocument.Parse(File.ReadAllText(path)); + string[] headlessOnly = limits.RootElement.GetProperty("headlessOnly").EnumerateObject().Select(p => p.Name).ToArray(); + string[] patterns = limits.RootElement.GetProperty("headlessEngineErrors").EnumerateObject().Select(p => p.Name).ToArray(); + var registered = Names.ToHashSet(); + + Assert.NotEmpty(headlessOnly); + Assert.All(headlessOnly, name => Assert.Contains(name, registered)); + // The CI job runs headless, so a suite that cannot pass there would hold it red for good. + Assert.All(headlessOnly, name => Assert.DoesNotContain(name, SuiteCatalog.CiTier)); + Assert.NotEmpty(patterns); + Assert.All(patterns, pattern => Assert.NotNull(new Regex(pattern))); + } + + [Fact] + public void Only_a_tier_term_whose_tier_says_so_makes_a_skip_fail() + { + var ci = TestHarness.SkipFailures("tier:ci"); + Assert.Equal(SuiteCatalog.CiTier.Count, ci.Count); + Assert.All(SuiteCatalog.CiTier, name => Assert.Equal("tier:ci", ci[name])); + + // The local battery's selectors keep SKIP as a non-failure, a listed suite named alone too. + Assert.Empty(TestHarness.SkipFailures("")); + Assert.Empty(TestHarness.SkipFailures("tier:quick")); + Assert.Empty(TestHarness.SkipFailures($"suite:{SuiteCatalog.CiTier[0]}")); + Assert.Empty(TestHarness.SkipFailures(SuiteCatalog.CiTier[0])); + Assert.Empty(TestHarness.SkipFailures("tier:nosuchtier")); + + // A union refuses a skip only from the ci tier's own members. + var union = TestHarness.SkipFailures("suite:chapter-census, tier:ci"); + Assert.False(union.ContainsKey("chapter-census")); + Assert.Equal(SuiteCatalog.CiTier.Count, union.Count); + } + [Fact] public void An_exact_term_selects_one_suite_and_a_substring_still_selects_many() { @@ -80,6 +138,10 @@ public void The_tier_term_selects_the_tier_in_registry_order() Assert.Equal( Names.Where(n => SuiteCatalog.QuickTier.Contains(n)).ToArray(), selected.Select(s => s.Name).ToArray()); + + var ci = TestHarness.Select(TestHarness.All, "tier:ci", out unmatched); + Assert.Empty(unmatched); + Assert.Equal(SuiteCatalog.CiTier, ci.Select(s => s.Name).ToArray()); } [Fact] diff --git a/CSVM.Tests/SyntheticDataTests.cs b/CSVM.Tests/SyntheticDataTests.cs new file mode 100644 index 000000000..a362f0154 --- /dev/null +++ b/CSVM.Tests/SyntheticDataTests.cs @@ -0,0 +1,201 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using CSVM.Effects; +using CSVM.Extraction; +using CSVM.Flight.Modes; +using CSVM.Mech3; +using CSVM.Tooling; +using CSVM.UI.Menu.Original; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The synthetic data root --synthetic-data writes, built from the fixtures the engine reads. +/// Its stamp passes the boot check as a current tree and marks it synthetic. Its chapter texture +/// archive opens through the path every session resolves, with one PNG per manifest entry. The +/// Original shell's files pass its availability check, each picture at its recorded size. Decoding +/// pixels needs Godot, so the in-engine run covers that half. +/// +public class SyntheticDataTests +{ + [Fact] + public void TheTreeIsStampedCurrentAndMarkedSynthetic() + { + string root = Built(); + + Assert.Equal(StampStanding.Current, ExtractionStamp.Standing(root, out int? found)); + Assert.Equal(ExtractionStamp.Schema, found); + Assert.True(SyntheticData.Marks(root)); + + var lines = new List(); + using (CSVM.Utils.Log.PushConsoleSink(lines.Add)) + { + ExtractionStamp.Check(root); + } + + Assert.Empty(lines); + } + + [Fact] + public void ARealLookingStampIsNotSynthetic() + { + string root = TestData.TempDir(); + Directory.CreateDirectory(Path.Combine(root, "extracted")); + File.WriteAllText( + Path.Combine(root, "extracted", "VERSION.json"), + $"{{ \"schema\": {ExtractionStamp.Schema}, \"assets\": {{ \"script\": \"CSVM\" }} }}"); + + Assert.False(SyntheticData.Marks(root)); + Assert.False(SyntheticData.Marks(TestData.TempDir())); + } + + [Fact] + public void TheChapterTextureArchiveOpensWithOnePngPerManifestEntry() + { + string root = Built(); + var lines = new List(); + using (CSVM.Utils.Log.PushConsoleSink(lines.Add)) + { + using var archive = new TextureArchive(SessionPaths.ChapterTextures(root, SyntheticTextures.Chapter)); + var entries = ManifestEntries(); + Assert.NotEmpty(entries); + Assert.Equal(entries.Count, archive.ManifestTextureCount); + foreach (var (name, width, height) in entries) + { + byte[]? png = archive.ReadPngBytes(name); + Assert.NotNull(png); + Assert.Equal(width, BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16))); + Assert.Equal(height, BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20))); + } + + Assert.Empty(archive.MissingTextures); + } + + Assert.Empty(lines); + } + + [Fact] + public void TheOriginalShellOpensOverTheTree() + { + string root = Built(); + + var layout = OriginalAvailability.Load(root, out string? reason); + + Assert.Null(reason); + Assert.NotNull(layout); + Assert.All(layout!.MissingArt, art => Assert.True(OriginalAvailability.IsMovie(art), art)); + Assert.NotNull(layout.Screen(OriginalAvailability.MainMenuSection)); + Assert.Equal("Agreed", layout.Screen("MessageBox")!.Widget("MB_B_CENTER")!.Text); + Assert.True(UiStrings.TryLoad(root)!.Has(10123)); + } + + [Fact] + public void EveryShellPictureIsWrittenAtItsRecordedSizeInItsOwnFormat() + { + string root = Built(); + using var doc = JsonDocument.Parse(File.ReadAllText(TestData.Fixture("synthetic", "rof", "art.json"))); + foreach (var entry in doc.RootElement.GetProperty("art").EnumerateArray()) + { + string name = entry.GetProperty("name").GetString()!; + byte[] bytes = File.ReadAllBytes(OriginalAvailability.ArtPath(root, name)); + var size = Path.GetExtension(name).ToLowerInvariant() switch + { + ".png" => (BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(16)), BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(20))), + ".tga" => (TgaImage.Decode(bytes)!.Width, TgaImage.Decode(bytes)!.Height), + _ => JpegFrameSize(bytes), + }; + + Assert.Equal((entry.GetProperty("width").GetInt32(), entry.GetProperty("height").GetInt32()), size); + } + } + + [Fact] + public void TheInventedMissionCarriesTwoBearingsACloudBandAndANetBlock() + { + string zrdr = SessionPaths.MissionZrdr(Built(), SyntheticMission.Chapter, SyntheticMission.Mission); + + // A zone change shows only where the two zones author different bearings. A whiteout + // shows only where the band's core sits above the ground. + var weather = WeatherState.Load(zrdr); + Assert.NotNull(weather); + Assert.NotEqual(weather!.Zone("zone1").SunOrientation, weather.Zone("zone2").SunOrientation); + Assert.Equal(1f, weather.WhiteoutAmount(weather.CloudBandCentre)); + Assert.Equal(0f, weather.WhiteoutAmount(0f)); + + var block = SpawnPoints.LoadNetFreeForAll(zrdr); + Assert.Equal(SpawnPoints.NetBlock, block?.Count); + Assert.Equal(block!.Count, block.Select(s => s.Position).Distinct().Count()); + } + + [Fact] + public void ARebuildReplacesWhatAnEarlierRunLeft() + { + string root = Built(); + string stray = Path.Combine(root, "extracted", "C1", "texture", "probe_stray.png"); + File.WriteAllBytes(stray, Array.Empty()); + + SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies); + + Assert.False(File.Exists(stray)); + } + + [Fact] + public void ATreeNotStampedSyntheticIsNeverReplaced() + { + string root = TestData.TempDir(); + string stamp = Path.Combine(root, "extracted", "VERSION.json"); + Directory.CreateDirectory(Path.GetDirectoryName(stamp)!); + File.WriteAllText(stamp, $"{{ \"schema\": {ExtractionStamp.Schema} }}"); + + Assert.Throws(() => SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies)); + Assert.True(File.Exists(stamp)); + Assert.False(SyntheticData.Marks(root)); + } + + [Fact] + public void AMissingFixturesFolderFailsTheBuildRatherThanLeavingAnEmptyTree() + { + string root = Path.Combine(TestData.TempDir(), "root"); + Assert.Throws( + () => SyntheticData.Build(Path.Combine(TestData.TempDir(), "absent"), root, SyntheticShell.TreeFamilies)); + Assert.False(SyntheticData.Marks(root)); + } + + private static string Built() + { + string root = Path.Combine(TestData.TempDir(), "root"); + SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies); + return root; + } + + // A baseline JPEG's frame header: the size follows the precision byte, height first. Every + // marker before it is a length-prefixed segment, which is all the synthetic writer emits. + private static (int Width, int Height) JpegFrameSize(byte[] jpeg) + { + Assert.True(jpeg[0] == 0xFF && jpeg[1] == 0xD8, "a JPEG starts with SOI"); + int at = 2; + while (jpeg[at + 1] != 0xC0) + { + at += 2 + BinaryPrimitives.ReadUInt16BigEndian(jpeg.AsSpan(at + 2)); + } + + return (BinaryPrimitives.ReadUInt16BigEndian(jpeg.AsSpan(at + 7)), BinaryPrimitives.ReadUInt16BigEndian(jpeg.AsSpan(at + 5))); + } + + private static List<(string Name, int Width, int Height)> ManifestEntries() + { + using var doc = JsonDocument.Parse(File.ReadAllText(TestData.Fixture("synthetic", "C1", "texture", "manifest.json"))); + var entries = new List<(string, int, int)>(); + foreach (var info in doc.RootElement.GetProperty("texture_infos").EnumerateArray()) + { + entries.Add((info.GetProperty("name").GetString()!, info.GetProperty("width").GetInt32(), info.GetProperty("height").GetInt32())); + } + + return entries; + } +} diff --git a/CSVM.Tests/SyntheticEffectsTests.cs b/CSVM.Tests/SyntheticEffectsTests.cs new file mode 100644 index 000000000..ee06fa57b --- /dev/null +++ b/CSVM.Tests/SyntheticEffectsTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Linq; +using CSVM.Mech3; +using CSVM.Tooling; +using CSVM.UI.Menu.Original; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The synthetic tree's anim and effect records. The compiled archive loads through the engine's +/// reader, and every definition anchors on a template root the gamez carries. The reader +/// destructibles and the two puffer readers parse. Staging and playing them needs Godot, so the +/// in-engine suites cover that half. +/// +public class SyntheticEffectsTests +{ + [Fact] + public void EveryCompiledDefinitionAnchorsOnATemplateRoot() + { + string root = Built(); + var gamez = GameZ.Load(SyntheticEffects.GamezUnder(root)); + var archive = AnimArchive.Load(SyntheticEffects.AnimUnder(root), SyntheticEffects.Folder); + + Assert.NotNull(archive); + Assert.Contains(archive.Defs, d => d.AnimName == "he_ground_effect"); + var children = gamez.Nodes.SelectMany(n => n.Children).ToHashSet(); + foreach (var def in archive.Defs.Where(d => d.Name != "player")) + { + var node = gamez.FindByName(def.Name); + Assert.True(node != null && !children.Contains(node.Index), $"{def.AnimName} anchors on {def.Name}"); + } + } + + [Fact] + public void EveryMeshedTemplateNodeHasItsBox() + { + var gamez = GameZ.Load(SyntheticEffects.GamezUnder(Built())); + + var meshed = gamez.Nodes.Where(n => n.MeshIndex >= 0).ToList(); + Assert.NotEmpty(meshed); + Assert.Equal(meshed.Count, gamez.Meshes.Count); + Assert.All(meshed, n => Assert.InRange(n.MeshIndex, 0, gamez.Meshes.Count - 1)); + } + + [Fact] + public void TheReaderDestructiblesAndPuffersParse() + { + string zrdr = Path.Combine(Built(), "extracted", "zrdr"); + var destructibles = AnimDefs.LoadFileDefs(zrdr, "probe_world.json"); + + Assert.Equal(2, destructibles.Count); + Assert.All(destructibles, d => Assert.True(d.Destructible && d.PersistLog && d.ResetState != null, d.Name)); + Assert.NotNull(PufferState.Load(zrdr, "flame_ball.json", "fierypuffer")); + Assert.NotNull(PufferState.Load(zrdr, "pufftrails.json", "smokepuffer")); + Assert.NotNull(PufferState.Load(zrdr, "pufftrails.json", "firepuffer")); + } + + [Fact] + public void AnArchiveEntryNamingNoFileFailsTheBuild() + { + string fixtures = TestData.TempDir(); + string folder = Path.Combine(fixtures, "synthetic", SyntheticEffects.Folder); + Directory.CreateDirectory(folder); + File.Copy(TestData.Fixture("synthetic", SyntheticEffects.Folder, "templates.json"), Path.Combine(folder, "templates.json")); + File.WriteAllText(Path.Combine(folder, "cam_anim.json"), "{ \"defs\": [ { \"def\": {} } ] }"); + string zrdr = Path.Combine(fixtures, "synthetic", "zrdr"); + Directory.CreateDirectory(zrdr); + foreach (string name in new[] { "probe_world.json", "flame_ball.json", "pufftrails.json" }) + { + File.Copy(TestData.Fixture("synthetic", "zrdr", name), Path.Combine(zrdr, name)); + } + + var tree = new SyntheticTree(fixtures, Path.Combine(TestData.TempDir(), "root")); + Assert.ThrowsAny(() => SyntheticEffects.Write(tree)); + } + + private static string Built() + { + string root = Path.Combine(TestData.TempDir(), "root"); + SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies); + return root; + } +} diff --git a/CSVM.Tests/SyntheticPlaneTests.cs b/CSVM.Tests/SyntheticPlaneTests.cs new file mode 100644 index 000000000..ad230dac0 --- /dev/null +++ b/CSVM.Tests/SyntheticPlaneTests.cs @@ -0,0 +1,303 @@ +using System.IO; +using System.Linq; +using CSVM.Flight.Airframe; +using CSVM.Flight.Camera; +using CSVM.Flight.Hangar; +using CSVM.Flight.Weapons; +using CSVM.Mech3; +using CSVM.Tooling; +using CSVM.UI.Menu.Original; +using Godot; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The stand-in aircraft the synthetic tree carries, read through the loaders a flight uses. That +/// covers its box-built model and marker rig, its player and AI defs, and its flight globals. It +/// also covers its one gun and one rocket, the shake sources and the stock fit that arms it. The +/// in-engine run flies it; these pin that every record resolves and the generated mesh faces out. +/// +public class SyntheticPlaneTests +{ + // The ids a custom plane's build composes: each calibre's slug gun and the pylons' stock ordnance. + private static readonly string[] CustomPlaneIds = Enumerable.Range(0, CustomPlaneDef.MaxCalibre + 1) + .Select(c => StockLoadouts.GunWeaponId(30 + (10 * c), "slug")).Append(Loadout.StockOrdnance).ToArray(); + + [Fact] + public void TheModelCarriesTheRootTheMarkerRigAndTheCockpit() + { + var gamez = GameZ.Load(Path.Combine(Built(), "extracted", "planes")); + + Assert.NotNull(gamez.FindByName(SyntheticPlane.Plane)); + foreach (string name in new[] { "geometry", "healthy", "markers", "dontmove", "destroyed", "cockpit1", + "cockpit_camera", "exhaust1", "exhaust2", "staticprop1", "prop1", "pcdp4", "pcdp6" }) + { + Assert.NotNull(gamez.FindByName(name)); + } + + var rig = MarkerRig.Extract(gamez, SyntheticPlane.Plane); + Assert.NotNull(rig); + Assert.Equal(8, rig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Firepoint)); + Assert.Equal(8, rig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Pylon)); + Assert.Single(rig.Markers, m => m.Kind == MarkerRig.MarkerKind.Target); + + // docs/formats/markers.md: odd pylons port, even starboard, |x| falling as the number rises. + var pylons = rig.Markers.Where(m => m.Kind == MarkerRig.MarkerKind.Pylon).OrderBy(m => m.Ordinal).ToList(); + Assert.All(pylons, p => Assert.True(p.Ordinal % 2 == 1 ? p.Local.X < 0f : p.Local.X > 0f)); + Assert.True(pylons[0].Local.X < pylons[2].Local.X && pylons[1].Local.X > pylons[3].Local.X); + } + + [Fact] + public void EveryGeneratedBoxIsClosedAndWoundOutward() + { + var gamez = GameZ.Load(Path.Combine(Built(), "extracted", "planes")); + + Assert.NotEmpty(gamez.Meshes); + foreach (var mesh in gamez.Meshes) + { + Assert.Equal(8, mesh.Vertices.Count); + Assert.Equal(6, mesh.Polygons.Count); + var center = mesh.Vertices.Aggregate(Vector3.Zero, (a, v) => a + v) / mesh.Vertices.Count; + foreach (var poly in mesh.Polygons) + { + Assert.InRange(poly.MaterialIndex, 0, gamez.Materials.Count - 1); + var corners = poly.VertexIndices.Select(i => mesh.Vertices[i]).ToList(); + var newell = Vector3.Zero; + for (int i = 0; i < corners.Count; i++) + { + var a = corners[i]; + var b = corners[(i + 1) % corners.Count]; + newell += new Vector3((a.Y - b.Y) * (a.Z + b.Z), (a.Z - b.Z) * (a.X + b.X), (a.X - b.X) * (a.Y + b.Y)); + } + + var faceCenter = corners.Aggregate(Vector3.Zero, (s, v) => s + v) / corners.Count; + Assert.True(newell.Dot(faceCenter - center) > 0f); + } + } + } + + [Fact] + public void ThePlayerAndAiDefsResolveWithEveryFlightRecord() + { + string zrdr = Path.Combine(Built(), "extracted", "zrdr"); + + var stats = PlaneStats.Load(zrdr, SyntheticPlane.Plane); + Assert.Equal("pprobe", stats.DefName); + Assert.Equal(0.85f, stats.EnginePower); + Assert.Equal(16f, stats.Gravity); + Assert.Equal(4, stats.DestroyableParts.Count); + Assert.Contains(stats.DestroyableParts, p => p.InjureAnims.Any(a => a.Anim == "pdpanel4")); + Assert.Equal(6, stats.CollisionProbes.Count); + + var ai = PlaneStats.LoadForAi(zrdr, SyntheticPlane.Plane); + Assert.Equal("probe", ai.AiDefName); + Assert.Equal(new[] { "wep_probe_gun", "wep_probe_rocket" }, ai.AiWeapons.Select(w => w.WeaponId)); + + Assert.NotEmpty(Maneuvers.Load(zrdr)); + Assert.True(AiSkills.Load(zrdr).QuickDrawChance(5) > 0f); + } + + [Fact] + public void TheGunAndTheRocketResolveTheirNamesAndTheStockFitArmsThem() + { + string root = Built(); + string zrdr = Path.Combine(root, "extracted", "zrdr"); + var messages = Messages.Load(Path.Combine(root, "extracted", "messages.json")); + + var weapons = WeaponDefs.Load(zrdr, messages); + var gun = weapons.Get("wep_probe_gun"); + Assert.NotNull(gun); + Assert.True(gun.IsGun); + var rocket = weapons.Get("wep_probe_rocket"); + Assert.NotNull(rocket); + Assert.Equal("Probe Gun", gun.DisplayName); + Assert.Equal("Probe Rocket", rocket.DisplayName); + + // The ordnance records beside them are invented too, and each names itself through the table. + // Only the ids a custom plane composes carry the code's names. + Assert.All(weapons.All, w => Assert.True(w.Id.StartsWith("wep_probe_", System.StringComparison.Ordinal) + || CustomPlaneIds.Contains(w.Id), w.Id)); + Assert.All(weapons.All, w => Assert.NotEqual(w.DescKey, w.DisplayName)); + // The fused blast rocket: a trigger distance past 8 m and a radius over twice it. + var blast = weapons.Get("wep_probe_blastrocket"); + Assert.NotNull(blast); + Assert.True(blast.IsRocket && blast.HighExplosive); + Assert.Equal("Probe Blast Rocket", blast.DisplayName); + Assert.True(blast.DetonationDistance > 8f && blast.ImpactProximity >= 2f * blast.DetonationDistance); + Assert.All(weapons.All, w => Assert.Empty(w.UnhandledKeys)); + + var shakes = ShakeDefs.Load(zrdr); + Assert.Equal(6, shakes.All.Count); + Assert.All(shakes.All, s => Assert.Empty(s.UnhandledKeys)); + + var fit = StockLoadouts.Load(SyntheticPlane.LoadoutsUnder(TestData.Fixture())).For("pprobe"); + Assert.NotNull(fit); + Assert.Equal(SyntheticPlane.Plane, fit.Model); + Assert.All(fit.Guns, g => Assert.Equal(gun.Id, g.WeaponId)); + Assert.NotNull(fit.Hardpoints); + Assert.All(fit.Hardpoints.Stock, id => Assert.Equal(rocket.Id, id)); + } + + [Fact] + public void TheFighterIsASecondDistinctAirframeWithItsOwnRigDefsAndFit() + { + string root = Built(); + var gamez = GameZ.Load(Path.Combine(root, "extracted", "planes")); + string zrdr = Path.Combine(root, "extracted", "zrdr"); + + var probeRig = MarkerRig.Extract(gamez, SyntheticPlane.Plane)!; + var fighterRig = MarkerRig.Extract(gamez, SyntheticPlane.Fighter); + Assert.NotNull(fighterRig); + Assert.Equal(8, fighterRig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Firepoint)); + Assert.Equal(8, fighterRig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Pylon)); + Assert.NotEqual(Marker(probeRig, "pylon1"), Marker(fighterRig, "pylon1")); + Assert.NotEqual(Marker(probeRig, "firepoint1"), Marker(fighterRig, "firepoint1")); + + // The two airframes fly apart: distinct defs, dynamics, engine row and engine sound. + var probe = PlaneStats.Load(zrdr, SyntheticPlane.Plane); + var fighter = PlaneStats.Load(zrdr, SyntheticPlane.Fighter); + Assert.Equal("pprobefighter", fighter.DefName); + Assert.NotEqual(probe.RollTorque, fighter.RollTorque); + Assert.NotEqual(probe.VehWeight, fighter.VehWeight); + Assert.NotEqual(probe.EnginePower, fighter.EnginePower); + Assert.NotEqual(probe.EngineSound, fighter.EngineSound); + Assert.Equal(6, fighter.CollisionProbes.Count); + Assert.Contains(fighter.DestroyableParts, p => p.InjureAnims.Any(a => a.Anim == "pdpanel4")); + Assert.Equal("probefighter", PlaneStats.LoadForAi(zrdr, SyntheticPlane.Fighter).AiDefName); + + // Laid over the committed config, the fighter's fit replaces the shipped plane on its model. + var stock = StockLoadouts.Load(Path.Combine(TestData.RepoRoot, "CSVM", "data", "stock_loadouts.json")); + Assert.NotNull(stock.ForModel(SyntheticPlane.Fighter)); + stock.Overlay(SyntheticPlane.LoadoutsUnder(TestData.Fixture())); + var fit = stock.ForModel(SyntheticPlane.Fighter); + Assert.NotNull(fit); + Assert.Equal("pprobefighter", fit.Def); + Assert.Single(stock.All.Values, d => d.Model == SyntheticPlane.Fighter); + Assert.All(fit.Guns, g => Assert.Equal("wep_probe_gun", g.WeaponId)); + Assert.Equal(4, fit.Hardpoints!.Count); + Assert.Equal(12, stock.All.Count); + } + + [Fact] + public void TheCustomPlaneAirframesAreDistinctAndArmTheIdsACustomBuildComposes() + { + string root = Built(); + var gamez = GameZ.Load(Path.Combine(root, "extracted", "planes")); + string zrdr = Path.Combine(root, "extracted", "zrdr"); + var fighterRig = MarkerRig.Extract(gamez, SyntheticPlane.Fighter)!; + var fighter = PlaneStats.Load(zrdr, SyntheticPlane.Fighter); + var stock = StockLoadouts.Load(Path.Combine(TestData.RepoRoot, "CSVM", "data", "stock_loadouts.json")); + stock.Overlay(SyntheticPlane.LoadoutsUnder(TestData.Fixture())); + + foreach (var (node, def, ai) in new[] { (SyntheticPlane.Firebrand, "pprobebrand", "probebrand"), + (SyntheticPlane.Avenger, "pprobeavenger", "probeavenger") }) + { + var rig = MarkerRig.Extract(gamez, node); + Assert.NotNull(rig); + Assert.Equal(8, rig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Firepoint)); + Assert.Equal(8, rig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Pylon)); + Assert.NotEqual(Marker(fighterRig, "pylon1"), Marker(rig, "pylon1")); + + var stats = PlaneStats.Load(zrdr, node); + Assert.Equal(def, stats.DefName); + Assert.NotEqual(fighter.RollTorque, stats.RollTorque); + Assert.NotEqual(fighter.EngineSound, stats.EngineSound); + Assert.Equal(ai, PlaneStats.LoadForAi(zrdr, node).AiDefName); + + // The engine pick's registry rows exist, so a custom engine reads its own power. + int airframe = StockAirframes.IdOf(node)!.Value; + for (int tier = 0; tier < 3; tier++) + { + Assert.NotNull(CustomPlaneBuild.EnginePowerFor(zrdr, new CustomPlaneDef { Airframe = airframe, Engine = tier })); + } + + var fit = stock.ForModel(node); + Assert.Equal(def, fit?.Def); + Assert.All(fit!.Guns, g => Assert.Equal("wep_probe_gun", g.WeaponId)); + } + + Assert.Equal(12, stock.All.Count); + var weapons = WeaponDefs.Load(zrdr, null); + Assert.All(CustomPlaneIds, id => Assert.NotNull(weapons.Get(id))); + Assert.True(weapons.Get(Loadout.StockOrdnance)!.HighExplosive); + } + + [Fact] + public void TheSixMoreShippedNamesAreDistinctAirframesWithTheirOwnDefsRowsAndFits() + { + string root = Built(); + var gamez = GameZ.Load(Path.Combine(root, "extracted", "planes")); + string zrdr = Path.Combine(root, "extracted", "zrdr"); + var fighterRig = MarkerRig.Extract(gamez, SyntheticPlane.Fighter)!; + var fighter = PlaneStats.Load(zrdr, SyntheticPlane.Fighter); + var stock = StockLoadouts.Load(Path.Combine(TestData.RepoRoot, "CSVM", "data", "stock_loadouts.json")); + stock.Overlay(SyntheticPlane.LoadoutsUnder(TestData.Fixture())); + + foreach (var (node, def, ai) in new[] + { + ("player_bhawk", "pprobehawk", "probehawk"), ("player_autogyro", "pprobegyro", "probegyro"), + ("player_fury", "pprobefury", "probefury"), ("player_peacemaker", "pprobepeace", "probepeace"), + ("player_warhawk", "pprobewarhawk", "probewarhawk"), ("player_kestrel", "pprobekestrel", "probekestrel"), + }) + { + var rig = MarkerRig.Extract(gamez, node); + Assert.NotNull(rig); + Assert.Equal(8, rig.Markers.Count(m => m.Kind == MarkerRig.MarkerKind.Firepoint)); + Assert.NotEqual(Marker(fighterRig, "pylon1"), Marker(rig, "pylon1")); + + var stats = PlaneStats.Load(zrdr, node); + Assert.Equal(def, stats.DefName); + Assert.NotEqual(fighter.EngineSound, stats.EngineSound); + Assert.Equal(node == "player_autogyro", stats.IsAutogyro); + Assert.Equal(ai, PlaneStats.LoadForAi(zrdr, node).AiDefName); + int airframe = StockAirframes.IdOf(node)!.Value; + Assert.NotNull(CustomPlaneBuild.EnginePowerFor(zrdr, new CustomPlaneDef { Airframe = airframe, Engine = 2 })); + Assert.Equal(def, stock.ForModel(node)?.Def); + } + + Assert.Equal(12, stock.All.Count); + } + + [Fact] + public void TheVariantsAndTheTurretTableResolveAgainstTheirAirframes() + { + string root = Built(); + var gamez = GameZ.Load(Path.Combine(root, "extracted", "planes")); + string zrdr = Path.Combine(root, "extracted", "zrdr"); + var weapons = WeaponDefs.Load(zrdr, null); + + // The variants a suite names derive from their airframe's own AI def. + var wingman = PlaneStats.LoadForAi(zrdr, "player_bhawk", "wbloodhawk"); + Assert.Equal("wingman", wingman.VehicleMode); + Assert.True(wingman.AiStructBias < wingman.AiTargetBias && wingman.AiTargetBias < 0f); + var hauler = PlaneStats.LoadForAi(zrdr, "player_warhawk", "bhatwarhawk"); + Assert.Contains(hauler.AiWeapons, w => weapons.Get(w.WeaponId) is { DamagesZeppelin: true }); + var cabbie = PlaneStats.LoadForAi(zrdr, "player_autogyro", "autogyro"); + Assert.Equal("agyro_rotors", cabbie.SpinPropsAnim); + Assert.NotNull(gamez.FindByName("rotor1")); + var fury = PlaneStats.LoadForAi(zrdr, "player_fury"); + Assert.True(fury.VehicleInjureAnims.Count(e => e.Anim == "random_remote_damage") > 1); + + // Each carried mount names a table row and a rig on its own model; one emplacement stands apart. + var turrets = TurretDefs.Load(zrdr); + Assert.Single(turrets.All, d => !d.Carried); + Assert.All(turrets.All, d => Assert.NotNull(weapons.Get(d.WeaponName))); + foreach (string node in new[] { "player_kestrel", SyntheticPlane.Firebrand }) + { + var mount = Assert.Single(PlaneStats.Load(zrdr, node).TurretMounts); + Assert.False(mount.FirstPerson); + Assert.True(turrets.FindByTitle(mount.Title) is { Carried: true }); + Assert.NotNull(gamez.FindByName(mount.Node)); + } + } + + private static Vector3 Marker(MarkerRig rig, string name) => rig.Markers.Single(m => m.Name == name).Local; + + private static string Built() + { + string root = Path.Combine(TestData.TempDir(), "root"); + SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies); + return root; + } +} diff --git a/CSVM.Tests/SyntheticSoundsTests.cs b/CSVM.Tests/SyntheticSoundsTests.cs new file mode 100644 index 000000000..c9f282e50 --- /dev/null +++ b/CSVM.Tests/SyntheticSoundsTests.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Linq; +using CSVM.Flight.Airframe; +using CSVM.Flight.Weapons; +using CSVM.Mech3; +using CSVM.Tooling; +using CSVM.UI.Menu; +using CSVM.UI.Menu.Original; +using Xunit; + +namespace CSVM.Tests; + +/// +/// The synthetic tree's sound archive and the WAV encoder behind it. Every definition the stand-in's +/// records name resolves. Every WAV decodes through the engine's reader as the MS ADPCM shape +/// docs/formats/sounds.md describes. The encoder round-trips a signal through that reader. +/// Playback needs Godot, so the in-engine run covers that half. +/// +public class SyntheticSoundsTests +{ + [Fact] + public void EveryDefinitionDecodesAsMonoAdpcmAtTheArchiveRate() + { + string extracted = Path.Combine(Built(), "extracted"); + var defs = SoundDefs.Load(Path.Combine(extracted, "zrdr")); + using var archive = new SoundArchive(Path.Combine(extracted, "soundsh")); + + Assert.NotEmpty(defs); + foreach (var def in defs.Values) + { + byte[]? bytes = archive.ReadWavBytes(def.WavName); + Assert.NotNull(bytes); + Assert.Equal(2, BitConverter.ToUInt16(bytes, 20)); + var wav = WavFile.Parse(bytes); + Assert.Equal(1, wav.Channels); + Assert.Equal(SyntheticSounds.Rate, wav.SampleRate); + Assert.True(wav.Frames > SyntheticSounds.Rate / 20, $"{def.WavName} decodes {wav.Frames} frames"); + } + } + + [Fact] + public void EveryGroupMemberIsADefinition() + { + string zrdr = Path.Combine(Built(), "extracted", "zrdr"); + var defs = SoundDefs.Load(zrdr); + var groups = SoundDefs.LoadGroups(zrdr); + + Assert.NotEmpty(groups); + Assert.All(groups.Values, g => Assert.All(g.Members, m => Assert.True(defs.ContainsKey(m.Name), m.Name))); + } + + [Fact] + public void ThePlaneAndItsWeaponsNameSoundsTheArchiveHolds() + { + string zrdr = Path.Combine(Built(), "extracted", "zrdr"); + var defs = SoundDefs.Load(zrdr); + var groups = SoundDefs.LoadGroups(zrdr); + var stats = PlaneStats.Load(zrdr, SyntheticPlane.Plane); + + foreach (string? name in new[] { stats.EngineSound, stats.CockpitEngineSound, stats.DamagedEngineSound, stats.RattleSound }) + { + Assert.NotNull(name); + Assert.True(defs.ContainsKey(name), name); + } + + // The engine loop is pitched and the cockpit and damaged loops are not, the split the + // format page decodes for the shipped definitions. + Assert.True(defs[stats.EngineSound].Frequency); + Assert.False(defs[stats.CockpitEngineSound!].Frequency); + Assert.False(defs[stats.DamagedEngineSound!].Frequency); + Assert.Contains(stats.WarningShotSound, groups.Keys); + Assert.Contains(stats.BulletHitSound, groups.Keys); + + var weapons = WeaponDefs.Load(zrdr, null); + Assert.Contains(weapons.EmptyClipSound, defs.Keys); + var cues = weapons.SoundCues(); + Assert.Contains(cues, c => c.Looped); + Assert.All(cues, c => Assert.True(defs.ContainsKey(c.Name), c.Name)); + } + + [Fact] + public void EveryAccentDealsAPilotWhoseWholeSetResolves() + { + string zrdr = Path.Combine(Built(), "extracted", "zrdr"); + var defs = SoundDefs.Load(zrdr); + var voice = new CombatVoice(defs, SoundDefs.LoadGroups(zrdr), CombatVoice.LoadAccents(zrdr)); + + Assert.NotEmpty(voice.AccentIds); + var pilots = voice.AccentIds.SelectMany(voice.Pool).Distinct().ToList(); + + // The Player Information voices the network suites seat are dealt by an accent too. + Assert.Contains(PilotVoices.SpeakerFor(PilotVoices.Wire(1))!.Value, pilots); + Assert.Contains(PilotVoices.SpeakerFor(PilotVoices.Wire(5))!.Value, pilots); + foreach (int pilot in pilots) + { + // The bearings are a per-pilot capability, and DA/DE resolve below the family root. + foreach (string family in CombatVoice.TriggerFamilies.Where(f => !f.StartsWith("WA-Enemy-") && f is not ("DA" or "DE"))) + { + Assert.True(voice.PlayableFor(pilot, family) != null, $"VO id {pilot} speaks {family}"); + } + + Assert.Equal(4, voice.ClipsFor(pilot, "DA").Count + voice.ClipsFor(pilot, "DE").Count); + Assert.EndsWith("_random", voice.PlayableFor(pilot, "DI-LowDmg")); + } + + Assert.Contains(pilots, p => voice.PlayableForTrigger(p, 6) != null); + } + + [Fact] + public void AManifestMissingAWavTheDefinitionsNameFailsTheBuild() + { + string fixtures = TestData.TempDir(); + string sounds = Path.Combine(fixtures, "synthetic", "zrdr", "sounds.json"); + string manifest = Path.Combine(fixtures, "synthetic", "soundsh", "manifest.json"); + Directory.CreateDirectory(Path.GetDirectoryName(sounds)!); + Directory.CreateDirectory(Path.GetDirectoryName(manifest)!); + File.Copy(TestData.Fixture("synthetic", "zrdr", "sounds.json"), sounds); + File.WriteAllText(manifest, "{ \"wavs\": [ { \"name\": \"probe_engine.wav\", \"seconds\": 0.1, \"hz\": 110 } ] }"); + + var tree = new SyntheticTree(fixtures, Path.Combine(TestData.TempDir(), "root")); + var thrown = Assert.Throws(() => SyntheticSounds.WriteArchive(tree)); + Assert.Contains("probe_gunloop.wav", thrown.Message); + } + + [Fact] + public void AdpcmRoundTripsATonePcmRoundTripsExactly() + { + short[] tone = SyntheticSounds.Signal("probe_round_trip", 0.25, 440); + + var adpcm = WavFile.Parse(WavWriter.MsAdpcm(tone, SyntheticSounds.Rate)); + Assert.Equal(tone.Length, adpcm.Frames); + double signal = tone.Sum(s => (double)s * s); + double noise = tone.Select((s, i) => (double)(s - adpcm.Samples[i]) * (s - adpcm.Samples[i])).Sum(); + + // Four bits a sample on a smooth tone keeps the error some 20 dB under the signal. + Assert.True(10 * Math.Log10(signal / noise) > 20, $"SNR {10 * Math.Log10(signal / noise):0.0} dB"); + + var pcm = WavFile.Parse(WavWriter.Pcm16(tone, SyntheticSounds.Rate)); + Assert.Equal(tone, pcm.Samples); + } + + private static string Built() + { + string root = Path.Combine(TestData.TempDir(), "root"); + SyntheticData.Build(TestData.Fixture(), root, SyntheticShell.TreeFamilies); + return root; + } +} diff --git a/CSVM.Tests/WaterQualitySettingTests.cs b/CSVM.Tests/WaterQualitySettingTests.cs new file mode 100644 index 000000000..4f2c318c5 --- /dev/null +++ b/CSVM.Tests/WaterQualitySettingTests.cs @@ -0,0 +1,95 @@ +using CSVM.Spec; +using CSVM.Utils; +using Xunit; + +namespace CSVM.Tests; + +/// The water quality's ladder and its platform fallback. Engine-free: the platform and the +/// GPU are handed in rather than asked of a renderer, which the unit host does not have. +public class WaterQualitySettingTests +{ + /// A desktop on a discrete GPU runs waves. Linux, which covers the Deck, and an + /// integrated GPU each run flat. + [Theory] + [InlineData(false, false, WaterQualitySetting.Waves)] + [InlineData(true, false, WaterQualitySetting.Flat)] + [InlineData(false, true, WaterQualitySetting.Flat)] + [InlineData(true, true, WaterQualitySetting.Flat)] + public void FallbackFollowsThePlatformAndTheGpu(bool linux, bool integrated, string expected) + { + Assert.Equal(expected, WaterQualitySetting.FallbackFor(linux, integrated)); + } + + /// A --det run falls back to waves whatever the machine, so a capture does not depend + /// on where it runs. + [Fact] + public void ADetRunFallsBackToWaves() + { + Assert.Equal(WaterQualitySetting.Waves, WaterQualitySetting.DefaultFor(det: true)); + } + + /// The flag beats the saved word, which beats the config key, which beats the fallback. + /// + [Fact] + public void TheFlagBeatsTheSavedWordWhichBeatsTheConfigKey() + { + var flag = WaterQualitySetting.Pick(WaterQualitySetting.Flat, WaterQualitySetting.Waves, WaterQualitySetting.Waves); + Assert.Equal((WaterQualitySetting.Flat, SettingSource.Flag), (flag.Word, flag.Source)); + Assert.Equal("--water-quality", WaterQualitySetting.Lookup.SourceName(flag.Source)); + + var saved = WaterQualitySetting.Pick(null, WaterQualitySetting.Flat, WaterQualitySetting.Waves); + Assert.Equal((WaterQualitySetting.Flat, SettingSource.Saved), (saved.Word, saved.Source)); + + var key = WaterQualitySetting.Pick(null, null, WaterQualitySetting.Flat); + Assert.Equal((WaterQualitySetting.Flat, SettingSource.Config), (key.Word, key.Source)); + Assert.Equal(WaterQualitySetting.Key, WaterQualitySetting.Lookup.SourceName(key.Source)); + + var fallback = WaterQualitySetting.Pick(null, null, WaterQualitySetting.Waves); + Assert.Equal((WaterQualitySetting.Waves, SettingSource.Default), (fallback.Word, fallback.Source)); + } + + /// The flat fallback names the rule that chose it. A choice of waves still beats it + /// there, since the rule moves a default, never a choice. + [Fact] + public void TheLowPowerFallbackHasItsOwnSourceAndAChoiceBeatsIt() + { + string fallback = WaterQualitySetting.FallbackFor(linux: true, integrated: false); + var none = WaterQualitySetting.Pick(null, null, fallback, fallback); + Assert.Equal((WaterQualitySetting.Flat, SettingSource.LowPowerDefault), (none.Word, none.Source)); + Assert.Equal("default_linux_or_integrated_gpu", WaterQualitySetting.Lookup.SourceName(none.Source)); + + var saved = WaterQualitySetting.Pick(null, WaterQualitySetting.Waves, fallback, fallback); + Assert.Equal((WaterQualitySetting.Waves, SettingSource.Saved), (saved.Word, saved.Source)); + + var key = WaterQualitySetting.Pick(null, null, WaterQualitySetting.Waves, fallback); + Assert.Equal((WaterQualitySetting.Waves, SettingSource.Config), (key.Word, key.Source)); + } + + /// An unknown word at every layer falls through to the fallback. + [Fact] + public void AnUnknownWordFallsThrough() + { + var unknown = WaterQualitySetting.Pick("choppy", "stormy", "glassy"); + Assert.Equal((WaterQualitySetting.Waves, SettingSource.Default), (unknown.Word, unknown.Source)); + } + + /// The command line takes the flag and drops an unknown word with a note. + [Fact] + public void TheCommandLineParsesTheFlagAndDropsAnUnknownWord() + { + Assert.Equal(WaterQualitySetting.Flat, SessionSpec.Parse(new[] { "--water-quality=flat" }).WaterQuality); + Assert.Equal(WaterQualitySetting.Waves, SessionSpec.Parse(new[] { "--water-quality=waves" }).WaterQuality); + Assert.Null(SessionSpec.Parse(new[] { "--water-quality=choppy" }).WaterQuality); + } + + /// The options file keeps a known word and drops an unknown one as never set. + [Fact] + public void TheOptionsFileKeepsAKnownWordAndDropsAnUnknownOne() + { + var kept = OptionsStore.Deserialize(OptionsStore.Serialize(new OptionsDef { WaterQuality = WaterQualitySetting.Flat })); + Assert.Equal(WaterQualitySetting.Flat, kept?.WaterQuality); + var dropped = OptionsStore.Deserialize("{\"version\":1,\"waterQuality\":\"choppy\"}"); + Assert.NotNull(dropped); + Assert.Null(dropped!.WaterQuality); + } +} diff --git a/CSVM.Tests/ZrdrTests.cs b/CSVM.Tests/ZrdrTests.cs index 82406f8de..4dad72b48 100644 --- a/CSVM.Tests/ZrdrTests.cs +++ b/CSVM.Tests/ZrdrTests.cs @@ -142,6 +142,40 @@ public void AnAbsentReaderFileThrowsRatherThanReturningEmpty() Assert.Throws(() => Zrdr.LoadFile(FixtureDir, "not_a_reader.json")); } + [Fact] + public void HasFileAnswersForAFolderUnderEitherStoredName() + { + var dir = TestData.TempDir(); + File.WriteAllText(Path.Combine(dir, "plain.json"), "[]"); + File.WriteAllText(Path.Combine(dir, "forked.zrd.json"), "[]"); + Assert.True(Zrdr.HasFile(dir, "plain.json")); + Assert.True(Zrdr.HasFile(dir, "forked.json")); + Assert.False(Zrdr.HasFile(dir, "absent.json")); + } + + [Fact] + public void HasFileAnswersForAZipUnderEitherStoredName() + { + var dir = TestData.TempDir(); + var source = Path.Combine(dir, "source"); + Directory.CreateDirectory(source); + File.WriteAllText(Path.Combine(source, "plain.json"), "[]"); + File.WriteAllText(Path.Combine(source, "forked.zrd.json"), "[]"); + var zipPath = Path.Combine(dir, "zrdr.zip"); + ZipFile.CreateFromDirectory(source, zipPath); + Assert.True(Zrdr.HasFile(zipPath, "plain.json")); + Assert.True(Zrdr.HasFile(zipPath, "forked.json")); + Assert.False(Zrdr.HasFile(zipPath, "absent.json")); + } + + [Fact] + public void HasFileIsFalseWhenTheArchiveItselfIsAbsent() + { + var dir = TestData.TempDir(); + Assert.False(Zrdr.HasFile(Path.Combine(dir, "zrdr"), "plain.json")); + Assert.False(Zrdr.HasFile(Path.Combine(dir, "zrdr.zip"), "plain.json")); + } + [Fact] public void LoadMatchingFilesSniffsContentBeforeParsing() { diff --git a/CSVM.Tests/fixtures/README.md b/CSVM.Tests/fixtures/README.md index 98dfd1f25..fd7b4ff85 100644 --- a/CSVM.Tests/fixtures/README.md +++ b/CSVM.Tests/fixtures/README.md @@ -9,7 +9,17 @@ instead by the golden-invariant tests, which read the player's own `extracted/` skip when it is absent. Byte-level fixtures (WAV/ADPCM) are not files at all: they are assembled field by field in -`WavFileTests.cs`, so the provenance of every byte is visible in the code that writes it. +`WavFileTests.cs`, so the provenance of every byte is visible in the code that writes it. The +synthetic tree's WAVs are generated the same way at run time, from a manifest of lengths and tones +(`CSVM/src/Tooling/SyntheticSounds.cs`). + +The engine reads this folder too, from the repo checkout: `--synthetic-data` assembles its +invented `extracted/` tree from files named here plus files generated in code +(`CSVM/src/Tooling/SyntheticData.cs`). A record a synthetic family copies keeps its path, or the +tree fails to build. Where the engine looks a record up by a name written in its own code (the HUD's +message keys, the music cues, the exhaust smoke sprites, the Player Information voices' VO ids, the +effect and template names an effect suite plays), the record carries that name over invented +content. | Path | Format page | Exercises | |---|---|---| @@ -28,4 +38,19 @@ Byte-level fixtures (WAV/ADPCM) are not files at all: they are assembled field b | `ia-minimal/ia.zrd.json` | `instant-action.md` | every optional key's built-in default, and the `dogfight_ace` wingmen/wave zero-forcing rule | | `ia-cli.json` | `instant-action.md` | the `--ia=` plain-JSON-object shape (not the zrdr flat-alternating one), a JSON-`null` wave | | `menu-layout/` | `menu-layout.md` | the sectioned-CSV grammar, `V` beating `G`, an unresolvable macro, every widget type's field order, the `ResID` → header → text join, the button colour tail and frame counts, the quoted scrapbook rectangle, a stray line and an unknown type | -| `menu-layout-original/` | `menu-layout.md`, `instant-action.md` | the Original shell's screens under the shipped widget keys on invented lines and invented `PM_*`/`PI_*` art: the main menu's panes and six buttons, the flight check's paper plaque, and the Instant Action section (the contents list, the dropdowns on shared lines for the two enemy pages, the radio pair, the text rows) | +| `ocean/default_l0_zoned.gdshader.txt` | none, this project's own shader | the wave ocean's shader text at the default sea (priority level 0, zone-gated), which every chapter draws with nothing saved; `OceanShaderTests` holds the generator to it byte for byte | +| `menu-layout-original/` | `menu-layout.md`, `instant-action.md` | the Original shell's screens under the shipped widget keys on invented lines and invented `PM_*`/`PI_*` art: the main menu's panes and six buttons, the flight check's paper plaque, the Instant Action section (the contents list, the dropdowns on shared lines for the two enemy pages, the radio pair, the text rows), and the ammunition screen's backdrop, plane diagrams and text rows; `--synthetic-data` decodes it into the tree's `menu_layout.json` | +| `synthetic/rof/ui_strings.json` | `strings.md` | `ui_strings.json`'s row shape (`id`, `symbol`, `font`, `text`, `dll`) with invented text: one row per `IDS_` symbol `menu-layout-original/` names, on invented ids, and rows with no symbol for ids the code reads directly (the lobby's three type lines) | +| `synthetic/rof/art.json` | `menu-layout.md` | an invented pixel size for every picture the Original shell draws, a button strip four frames tall (eight for a check or radio) and a pane strip its `NumFrames`; one PNG, JPEG or TGA per entry is generated at that size | +| `synthetic/C1/texture/manifest.json` | `docs/org/textures.md` | the extraction manifest's `texture_infos` (`name`, `width`, `height`, `alpha`, `stretch`) for the synthetic C1 texture archive, the stand-in's skins and the exhaust trail's `smoke101`..`103`; one PNG per entry is generated at that size | +| `synthetic/planes/` | `gamez.md`, `markers.md` | ten stand-ins' legacy-shape trees, `probe_plane`, `player_pfighter` (the lobby door's starter airframe by the name the code gives it, on invented boxes of other proportions and its own marker positions), and `player_fbrand` and `player_avenger` (the custom-plane suites' host and guest airframes, ids 6 and 1, by the code's names: the fighter's invented subtree rescaled long and narrow, and broad and heavy, each on its own colour), and six more under shipped airframe names the suites fly, `player_bhawk`, `player_autogyro`, `player_fury`, `player_peacemaker`, `player_warhawk` and `player_kestrel` (the fighter's invented subtree rescaled on six other proportions and colours), each with `geometry` → `healthy` → one LOD, the `markers` rig (eight firepoints in mirror pairs, eight pylons odd to port, `target`, `cockpit_camera`, two exhausts), `dontmove` props, `destroyed` pieces, `pdpN` torn panels and a `cockpit1` interior with `pcdp4`/`pcdp6`; the probe's interior also carries a `probe_lamp_on` lamp and a `probe_holes` group over three `bul9a`..`bul9c` quads, names the builder's own parking predicate takes as driven states, and the fighter carries two `wing_flareN` quads. The autogyro also carries a `rotor1` blade in its `dontmove` group, and the kestrel and the fbrand each a turret rig (`probe_kestrel_turret1`, `probe_brand_turret1`, each holding `pturret` → `pgun` → `pfirepoint`) that its def's `thirdp` mount names. `boxes.json` lists one box per mesh index, and `models.json` is generated from it | +| `synthetic/zrdr/vehicle.json`, `engines.json`, `player.json` | `vehicle.md`, `vehicle/player-globals.md`, `ai-rosters.md` | the `pprobe` player def (`nodename probe_plane`, dynamics, four zones with `pdpanelN` injure entries, six collision probes) and its `probe` AI flavour with a `weapons` block; the `pprobefighter` player def (`nodename player_pfighter`, other dynamics, engine row and engine sound), its `probefighter` jet flavour and a `wingman` def under it (the name the campaign wingman reads); the `pprobebrand` and `pprobeavenger` player defs (`nodename player_fbrand` and `player_avenger`, each with its own dynamics, zones, collision probes and engine sound) and their `probebrand` and `probeavenger` jet flavours, the fbrand's pair naming a `thirdp` turret mount; six more player defs under shipped airframe names (`pprobehawk`, `pprobegyro`, `pprobefury`, `pprobepeace`, `pprobewarhawk`, `pprobekestrel`), each with its own dynamics, zones, collision probes, engine row and sound and a jet flavour named without the `p`, carrying what the code keys on: `is_autogyro` and an `agyro_rotors` spin definition on the autogyro, a `thirdp` rear mount on the kestrel, an AI ladder repeating `random_remote_damage` on the fury (whose zones carry light armour, so a ground graze reaches health); and under the names suites spawn, `wbloodhawk` (a wingman with both target biases), `bhatwarhawk` (a hull torpedo and a gun) and `autogyro` (the Cabbie, deriving from the autogyro's flavour); the base def's three engine-slot sounds; 27 engine rows, the probe's three and three for each stand-in but the fighter on the row ids `CustomPlaneBuild.EngineRegistryBase` composes, so a custom engine pick reads an invented power; flight globals, the `crash` block, the `rattle` block, the two incoming-fire groups and every `ai_skill_parameters` pair and a suicide score unlike the executable's fallback (`docs/org/multiplayer-scoring.md`), and the three `smokescreen_stun_*` keys (450 m, 160°, 4 s), wide enough that the smoke suite's AI 300 m astern sits inside the cone and its AI 400 m out at 97° outside it, and the four `sticky_bullet_*` aim-assist globals and `voiceover_volume_limiter`, each unlike both the executable's fallback and the shipped figure, so a reader that misses the key shows. The tree also copies `zrdr/maneuvers.json` above as its maneuver library | +| `synthetic/zrdr/weapons.json`, `shakes.json`, `synthetic/messages.json` | `weapons.md`, `shakes.md`, `missions.md` | one gun (`wep_probe_gun`, with its firing loop) one rocket (`wep_probe_rocket`, with its launch sound) and a fused HE rocket (`wep_probe_blastrocket`: an 11 m trigger, a 66 m blast and a speed that crosses the air-to-air suite's fixed pass gaps); one record per ordnance class the ordnance suites fly, each value chosen so the suite's behaviour is reachable in its own layout: `wep_probe_torpedo` (`TORPEDO`, `TARGETABLE`, a 12-point `FLYOUT_HEALTH`, a 240 m `RANGE_MINIMUM`, 50 m/s over a 2 s `LOCK_ON`, a 36 m blast wider than the blast lab's 23.5 m layout), `wep_probe_he` (`HIGH_EXPLOSIVE` at 900 m/s with a `SURFACE_ANIMATION` row), `wep_probe_choker` (`TANGLER` of 1.6 s, 30 m and `ENGINE_DEAD` 4 to 11, no `LOCK_ON`, fast enough to cross a 30 m drop onto a rig in two steps), `wep_probe_flare` (a 1.5 s `DETONATION_TIME` at 1.2 m/s, no `RANGE`), `wep_probe_sonic` (a 34 m fuse and radius, so a hull 29 to 33 m off sits in the fade band), `wep_probe_flash` (`FLASH` over 380 m), `wep_probe_seeker` (`BEEPER_SEEKER` turning 1.1 rad/s at 300 m/s, slow enough not to reach the rigs it steers at), `wep_probe_beeper` (a 14 s `BEEPER` at 960 m/s with a 14 m fuse, whose 16 m steps put one sample 10 m short of a rig 410 m out, and a named-and-empty `default` row), `wep_probe_flak` (a 40 m fuse, distinct `default` and `player` rows), `wep_probe_incendiary` (100 m/s² to a 280 m/s cap between 2 s and 3 s, no `LOCK_ON`, a `LOCK_ON_LEAD` onset past its `RANGE`) and `wep_probe_smoker` (a 6.5 s `SMOKE_SCREEN`, `REAR`), and `wep_probe_gunhit`, a gun whose default and player rows both play `3040slug_gunhit`; the torpedo also names the template gamez's flyout body and its trail def under `FLYOUT`. After them, six records carry the ids a custom plane's build composes, the code's names over invented values: one slug gun per calibre row, `wep_30` to `wep_70` (`StockLoadouts.GunWeaponId`), slower and heavier as the calibre rises, and an HE pylon rocket under `wep_06` (`Loadout.StockOrdnance`); they sit last, so a suite that takes the first record of a class still flies a `wep_probe_*` one, as does `wep_probe_zeptorp` after them, a `DAMAGES_ZEPPELIN` torpedo the `bhatwarhawk` def carries. An `IMPACT` row names the effect the engine's own catalogue keys on (`he_ground_effect`, `scatter_effect`, `flak_effect` and the rest) over invented values. Beside them sit the six shake sources (the nitro block's frequency and damp put its ramp-law swing near 15 ticks, apart from its authored rate) and the strings the flight reads, each a `MSG_PROBE_*` key (one per weapon `DESC`) or an invented wording of a key the HUD names (the kill lines, the clock's two notices and the two Dogfight templates), and among the rest a Dogfight's turret line, the one-life line, the ending's two lines and the rearm line, and Capture the Flag's base, flag-at-base, captured-by and floating markers, its Your/Enemy words and the carrier's tag, each side's pair worded apart and each name row carrying its `%1` (and `%2`) | +| `synthetic/zrdr/sounds.json`, `synthetic/soundsh/manifest.json` | `sounds.md` | the stand-in's sound library: the engine loop (`FREQUENCY`) and one per other airframe, its unpitched cockpit and damaged swaps, the rattle, the gun loop and dry click (`3D` + `RANGE`), fire, launch and impact one-shots, the pass and ricochet groups `player.json` names, a 45 s radio line and a 0.5 s bark (`QUEUE`), and the music cues `MusicPlayer` names over `snd_probe_*` members, two takes per stinger, and `snd_propstart`, the propeller start the spawn suite looks for by name; and two pilots' combat-voice sets under the `snd_id_` names `CombatVoice` builds, for VO ids 2 and 26, the ids the code's Player Information table (`PilotVoices`) gives its second and sixth voices: an `-A` take of every trigger family with `PURGEABLE` and `QUEUE [0.5]`, a `-B` take of `DI-LowDmg`, the `Bail`/`NoBail` split of `DA` and `DE`, the twelve bearing call-outs for id 2 alone, and a `snd_-A_id_random` group per family, over invented `probe_vo_*.wav` files. The manifest gives each WAV a length and a tone or noise; one MS ADPCM WAV per entry is generated into `soundsh/` | +| `synthetic/zrdr/ai.json` | `turrets.md` | the `TURRET` section: two carried entries (`CREATE_STANDALONE 0`, awake) whose titles the kestrel's and the fbrand's `thirdp` mounts name, a rear one on a directed yaw arc and a dorsal one on a free yaw, and one dormant standalone entry under the `aagun**` pattern and the `turret`/`gun`/`firepoint` chain the sight-line suites build, each firing `wep_probe_gun`, with its title in `messages.json` | +| `synthetic/zrdr/voice.json` | `combat-voice.md` | the accent table's `[accentId, voId, …]` rows: one two-pilot pool dealt in order and two one-pilot pools, over the two voiced ids above | +| `synthetic/C1/PROBE1/zrdr/weather.json`, `net.json` | `weather.md`, `net-spawns.md` | the one invented mission scope, a name no shipped mission carries: two zones whose `SUNLIGHT_ORIENTATION` differ (-50°/60° and -20°/150°, so a zone change moves the light), each with fog and `SUNLIGHT_*` levels, under a 2200 to 2600 m `CLOUD_COVER` band with an 80 m opaque core, so a camera on the ground sits in state 1 and one at 25 km in state 2; and one 16-entry free-for-all block of four-float `[x, y, z, heading°]` nodes on four rungs, all distinct | +| `synthetic/stock_loadouts.json` | `loadouts.md` | the stand-ins' stock fits, their guns named by `weapon` rather than caliber; read in place as `StockLoadouts.Supplement`, not written into the tree, the fighter's fit and the other eight stand-ins' fits each replacing the committed plane on its model | +| `synthetic/probe_effects/templates.json` | `gamez.md` | the `effects` family's template gamez as a compact tree (name, offset, box, material), from which `--synthetic-data` writes a legacy-shape `nodes.json`, one box per meshed node in `models.json`, and `materials.json`. The roots carry the names the engine's effect catalogue or a suite looks up (`he_ring`, `he_ring1`, `he_trails`, `dum_gunhit`, `ballflare.flt`, `planeflakes`, `sonic_effect`, `sonic_ring1`/`2`, the crash callees' roots) over invented boxes, the upper ring standing in XY as `ProjectilePool.UpperRingDiscNormal` states, plus an invented torpedo flyout body (`atbody`, `rightwing`, `leftwing`, `atprop`) and two destructibles with `healthy`/`destroyed` roles. Motions drive children, never a root, since a placement moves the root | +| `synthetic/probe_effects/cam_anim.json` | `anim-definitions.md`, `anim-definitions/compiled-archives.md` | the compiled archive shape (one entry per definition file, split into `metadata.json` and `-.json`): the HE burst (an upper-ring call 9 m up, five ranged debris launches), a gun hit that ends at 0.1 s, the seeker's flare, the sonic burst's two rings ending inactive at opacity 0 with a `reset_state` that re-shows them, three panel tears calling a three-flake burst (one of them four times at +2/-2 m on 0.2/0.5/0.9 s offsets), the water crash with one `wait_for_completion` call to a 3 s splash whose puffer host goes inactive in its own instant, a building death whose `sparkout3` sequence switches the trailing part off at 1.8 s, a bullethole def whose call sits in the `Else` of a first-person `If`, the propeller start, spin and stop, the autogyro's `agyro_rotors` spin (its `reset_state` turns `prop1` and `rotor1` on), and the torpedo flyout's folded `reset_state` unfolding at 3.5 s and 4.0 s. Every value is invented | +| `synthetic/zrdr/probe_world.json`, `flame_ball.json`, `pufftrails.json` | `anim-definitions.md`, `effects.md`, `destructibles.md` | reader-form definitions: two `PERSIST_LOG` destructibles (`HEALTH`, a `RESET_STATE`, a death sequence swapping the roles, one with an `ANIM_HEALTH` damage stage), and the puffers code reads by name, `fierypuffer` (a burst with a 0.75 s life and a three-frame flipbook) and `smokepuffer`/`firepuffer` (distance trails, the first with a `COLORS` ramp) | diff --git a/CSVM.Tests/fixtures/menu-layout-original/LAYOUT.CSV b/CSVM.Tests/fixtures/menu-layout-original/LAYOUT.CSV index afce78437..d8fa95afb 100644 --- a/CSVM.Tests/fixtures/menu-layout-original/LAYOUT.CSV +++ b/CSVM.Tests/fixtures/menu-layout-original/LAYOUT.CSV @@ -210,7 +210,22 @@ SBTOC_B_REPLAY=B,PM_B_Paper.png,600,500,0,IDS_PM_CHANGEPLANE,,0x1000,0,,,,,,,0xF SBTOC_B_CURRENT=B,PM_B_Current.png,560,10,0,IDS_PM_CHANGEPLANE,,0x1000,0,,,,,,,0xFF767676,0xFF133D77,0xFF374D6B,0xFF000000 SBTOC_B_RETURN=B,PM_B_ReturnCabin.png,590,560,0,0,PassengerCabin,0x1000,1,,,,,, +;The ammunition screen's backdrop, its two plane diagrams with one frame per stock airframe, and +;the text rows the screen fills: the title, the fitted aircraft's line the script supplies, the +;two list headings and captions, the gun caption beside the first field and the two pane headings. [@OrdinanceLayout@] +OL_BACKGROUND=P,PM_Ammo.jpg,0,0,1,1,0,0,0 +OL_P_PLANETOPICON=P,PM_PlaneTop.png,140,420,2,11,0,1,1 +OL_P_PLANEFRTICON=P,PM_PlaneFront.png,300,420,2,11,0,1,1 +OL_T_TITLE=T,IDS_OL_TITLE,120,30,0,300,30,0xFF000000,0 +OL_T_PLANEINFO=T,!,120,64,0,300,20,0xFF000000,0 +OL_T_AMMOTITLE=T,IDS_OL_AMMO_TITLE,130,100,0,150,18,0xFF000000,0 +OL_T_AMMOCAPTION=T,IDS_OL_AMMO_CAPTION,130,150,0,150,18,0xFF000000,0 +OL_T_GunName0=T,,290,125,0,120,18,0xFF000000,0 +OL_T_ROCKETTITLE=T,IDS_OL_ROCKET_TITLE,130,295,0,150,18,0xFF000000,0 +OL_T_ROCKETCAPTION=T,IDS_OL_ROCKET_CAPTION,130,375,0,150,18,0xFF000000,0 +OL_T_AMMODESCTITLE=T,IDS_OL_AMMODESC_TITLE,560,76,0,180,18,0xFF000000,0 +OL_T_ROCKETDESCTITLE=T,IDS_OL_ROCKETDESC_TITLE,560,316,0,180,18,0xFF000000,0 OL_S_AMMODESC=S,0xFF554C67,0xFFE6DABE,PM_B_Slider.png,PM_B_Up.png,PM_B_Down.png,560,100,0,180,190,,0xFF000000 OL_S_ROCKETDESC=S,0xFF554C67,0xFFE6DABE,PM_B_Slider.png,PM_B_Up.png,PM_B_Down.png,560,340,0,180,199,,0xFF000000 OL_D_AMMO0=D,PM_B_Slider.png,PM_B_Up.png,PM_B_Down.png,PM_B_DropUp.png,PM_B_DropDown.png,130,125,0,150,16,6 diff --git a/CSVM.Tests/fixtures/ocean/default_l0_zoned.gdshader.txt b/CSVM.Tests/fixtures/ocean/default_l0_zoned.gdshader.txt new file mode 100644 index 000000000..6dc8932bc --- /dev/null +++ b/CSVM.Tests/fixtures/ocean/default_l0_zoned.gdshader.txt @@ -0,0 +1,340 @@ +shader_type spatial; +render_mode skip_vertex_transform, cull_disabled; +#include "res://shaders/csky_time.gdshaderinc" +#include "res://shaders/csky_atmosphere.gdshaderinc" +#include "res://shaders/csky_mip_bias.gdshaderinc" +uniform sampler2D mask_tex : filter_linear, repeat_disable; +uniform vec4 mask_rect; +uniform sampler2D albedo_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform float tile_m = 32.0; +uniform sampler2D tint_tex : source_color, filter_linear, repeat_disable; +uniform sampler2D lift_tex : hint_default_black, filter_linear, repeat_disable; +uniform float rough_near = 0.2; +uniform float rough_far = 0.3; +uniform float wave_scale = 1.0; +uniform float foam_strength = 0.12; +uniform vec4 calm_axes[16]; +uniform vec4 calm_sizes[16]; +uniform sampler2D zone_tex : filter_nearest, repeat_disable; +instance uniform float zone_index = 0.0; +const vec4 SWELL_DKA[8] = vec4[8](vec4(0.939693, 0.34202, 0.041337, 0.967662), vec4(0.62932, 0.777146, 0.053702, 0.782087), vec4(0.997564, -0.069756, 0.067561, 0.666063), vec4(0.258819, 0.965926, 0.088496, 0.5311), vec4(0.891007, -0.453991, 0.110231, 0.453592), vec4(0.829038, 0.559193, 0.1428, 0.364147), vec4(0.62932, -0.777146, 0.1848, 0.281386), vec4(0.529919, 0.848048, 0.241661, 0.248282)); +const vec2 SWELL_QW[8] = vec2[8](vec2(1.71875, 0.637045), vec2(1.636905, 0.726057), vec2(1.527778, 0.813323), vec2(1.462766, 0.932006), vec2(1.375, 1.040216), vec2(1.322115, 1.183333), vec2(1.322115, 1.345649), vec2(1.145833, 1.53938)); +const vec4 SWELL_MOD[8] = vec4[8](vec4(0.8, 0.0, 2.08702, 1.34006), vec4(-0.589895, 0.540392, -0.823276, -3.115196), vec4(0.06994, -0.796937, -1.883608, 3.589463), vec4(0.486751, 0.634881, 4.995227, -1.800273), vec4(-0.787771, -0.139345, -6.10275, -2.54948), vec4(0.675004, -0.429383, 3.598552, 7.775649), vec4(-0.207684, 0.772572, 3.36329, -10.56558), vec4(-0.368725, -0.709959, -12.14262, 6.968275)); +const vec4 SWELL_FINE[8] = vec4[8](vec4(-0.0, 0.0, 0.0, -0.0), vec4(-0.0, -0.0, -0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0), vec4(-0.0, 0.0, -0.0, -0.0), vec4(0.0, -0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, -0.0), vec4(-0.184821, -0.153105, -0.183448, 0.02231), vec4(0.559305, -0.027883, 0.458727, 0.327911)); +const vec4 DETAIL_L[5] = vec4[5](vec4(0.951057, -0.309017, 0.166667, 0.284445), vec4(0.224951, 0.97437, 0.294118, 0.568889), vec4(0.62932, -0.777146, 0.526316, 0.568889), vec4(0.829038, 0.559193, 0.952381, 0.853333), vec4(1.0, 0.0, 1.724138, 1.137778)); +const vec2 DETAIL_G[5] = vec2[5](vec2(0.751457, 0.27443), vec2(-0.739477, 0.305244), vec2(0.339077, -0.724588), vec2(0.239427, 0.763331), vec2(-0.692168, -0.401127)); +const float FOAM_JAC_SIGMA = 0.1375; +varying vec2 v_param; + +vec2 ocean_mask(vec2 p) { + return textureLod(mask_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).rg; +} + +// The swell height and the look left at the mask's shore distance g. +float shore_swell(float g) { + return smoothstep(0.15, 1.0, g); +} + +float shore_look(float g) { + return smoothstep(0.075, 0.4, g); +} + +// Two hashes in [0, 1] per integer cell, without sin, so it costs little per wave sum. +vec2 sea_hash(vec2 c) { + vec3 p3 = fract(c.xyx * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +// Two smooth value-noise channels in [-1, 1]. +vec2 sea_noise(vec2 p) { + vec2 c = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(sea_hash(c), sea_hash(c + vec2(1.0, 0.0)), f.x), + mix(sea_hash(c + vec2(0.0, 1.0)), sea_hash(c + vec2(1.0, 1.0)), f.x), f.y) * 2.0 - 1.0; +} + +// sea_noise with its slope: d's columns are the two channels' change along x and along y. +vec2 sea_noise_d(vec2 p, out mat2 d) { + vec2 c = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + vec2 du = 6.0 * f * (1.0 - f); + vec2 a = sea_hash(c); + vec2 k1 = sea_hash(c + vec2(1.0, 0.0)) - a; + vec2 k2 = sea_hash(c + vec2(0.0, 1.0)) - a; + vec2 k3 = sea_hash(c + vec2(1.0, 1.0)) - a - k1 - k2; + d = mat2(2.0 * du.x * (k1 + k3 * u.y), 2.0 * du.y * (k2 + k3 * u.x)); + return (a + k1 * u.x + k2 * u.y + k3 * (u.x * u.y)) * 2.0 - 1.0; +} + +// The grouping channels at p. Each field is turned off the grid's axes so the noise cells do not +// line up with each other. +vec2 sea_group(vec2 p) { + return sea_noise(mat2(vec2(0.8, 0.6), vec2(-0.6, 0.8)) * p * 0.002); +} + +// The phase (warp) channels at p in xy, and its fine octave alone in zw. The out matrix is the +// slope of xy per metre. A wave's local wavenumber is its own plus its phase weights times it. +vec4 sea_warp(vec2 p, out mat2 d) { + const mat2 rc = mat2(vec2(0.6, -0.8), vec2(0.8, 0.6)); + const mat2 rf = mat2(vec2(0.28, 0.96), vec2(-0.96, 0.28)); + mat2 dc; + mat2 df; + vec2 c = sea_noise_d(rc * p * 0.002778 + vec2(17.0, 5.0), dc); + vec2 f = sea_noise_d(rf * p * 0.011111 + vec2(-9.0, 31.0), df); + d = dc * rc * 0.001389 + df * rf * 0.005556; + return vec4(c * 0.5 + f * 0.5, f); +} + +// A wave's local wavenumber vector: its own plus the warp's slope along its phase weights. Its +// length is capped at WarpStretchMax times the wave's own, so the fades keep their order. +vec2 sea_wavevector(vec4 w, vec4 coarse_w, mat2 d) { + vec2 k = w.z * w.xy + coarse_w.zw * d; + return k * max(1.0, w.z / (1.5 * max(length(k), 1e-4))); +} + +// The fine field at p: xy its grouping channels, zw its phase channels. +vec4 sea_fine(vec2 p) { + vec2 g = sea_noise(mat2(vec2(0.96, -0.28), vec2(0.28, 0.96)) * p * 0.025 + vec2(41.0, -23.0)); + vec2 f = sea_noise(mat2(vec2(-0.6, 0.8), vec2(-0.8, -0.6)) * p * 0.035714 + vec2(-57.0, 13.0)); + return vec4(g, f); +} + +// Two gradients in [-1, 1] per integer cell. The cell's x is taken modulo DetailPeriod, so a +// layer's noise repeats along its drift axis and its drift wraps with csky_time. +vec2 detail_hash(vec2 c) { + return sea_hash(vec2(mod(c.x, 1024.0), c.y)) * 2.0 - 1.0; +} + +// Gradient noise at q: x the value, yz its slope along q's axes. +vec3 detail_noise(vec2 q) { + vec2 i = floor(q); + vec2 f = fract(q); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 du = 30.0 * f * f * (f * (f - 2.0) + 1.0); + vec2 ga = detail_hash(i); + vec2 gb = detail_hash(i + vec2(1.0, 0.0)); + vec2 gc = detail_hash(i + vec2(0.0, 1.0)); + vec2 gd = detail_hash(i + vec2(1.0, 1.0)); + float va = dot(ga, f); + float vb = dot(gb, f - vec2(1.0, 0.0)); + float vc = dot(gc, f - vec2(0.0, 1.0)); + float vd = dot(gd, f - vec2(1.0, 1.0)); + float k = va - vb - vc + vd; + return vec3(va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * k, + ga + u.x * (gb - ga) + u.y * (gc - ga) + u.x * u.y * (ga - gb - gc + gd) + du * (u.yx * k + vec2(vb, vc) - va)); +} + +// Gradient noise at q, about -0.7 to 0.7, smooth across its cells. The cell's x repeats every +// period cells. The half cell keeps the modulo exact, so no corner hashes differently in two cells. +float foam_noise(vec2 q, float period) { + vec2 i = floor(q); + vec2 f = fract(q); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 x = vec2(i.x, i.x + 1.0); + x -= period * floor((x + 0.5) / period); + float a = dot(sea_hash(vec2(x.x, i.y)) * 2.0 - 1.0, f); + float b = dot(sea_hash(vec2(x.y, i.y)) * 2.0 - 1.0, f - vec2(1.0, 0.0)); + float c = dot(sea_hash(vec2(x.x, i.y + 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0)); + float d = dot(sea_hash(vec2(x.y, i.y + 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// The holes in a sheet of foam: one round hole per jittered cell, each its own size. Returns the +// distance from q to the nearest hole's centre in that hole's radii, so under 1 inside a hole. +float foam_holes(vec2 q) { + vec2 i = floor(q); + vec2 f = fract(q); + float s = 9.0; + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 o = vec2(float(x), float(y)); + vec2 h = sea_hash(i + o); + vec2 r = o + h * 0.8 + 0.1 - f; + s = min(s, length(r) / (0.35 + 0.4 * fract(h.x * 7.31 + h.y * 3.17))); + } + } + return s; +} + +// Where whitecaps can form, about 0 to 1, at pw (metres along and across the wind). +float foam_patches(vec2 pw) { + return 0.5 + 0.65 * foam_noise(pw * 0.004348 - vec2(csky_time * (23.0 / 3600.0), 0.0), 23.0) + 0.35 * foam_noise(pw * 0.014085 - vec2(csky_time * (76.0 / 3600.0), 0.0), 76.0); +} + +// One wave's height factor and phase shift from its weights on both fields. +vec2 sea_mod(vec4 coarse_w, vec4 fine_w, vec2 group, vec2 warp, vec4 fine) { + return vec2(clamp(1.0 + dot(coarse_w.xy, group) + dot(fine_w.xy, fine.xy), 0.0, 1.8), + dot(coarse_w.zw, warp) + dot(fine_w.zw, fine.zw)); +} + +// OceanCalmZone.Distance and Calm, per ship: flat within the margin of the box, full height a fade +// further out. +float ship_calm(vec2 p) { + float a = 1.0; + for (int i = 0; i < 16; i++) { + vec2 q = p - calm_axes[i].xy; + vec2 u = calm_axes[i].zw; + vec2 e = max(abs(vec2(dot(q, u), dot(q, vec2(-u.y, u.x)))) - calm_sizes[i].xy, vec2(0.0)); + a = min(a, smoothstep(calm_sizes[i].z, calm_sizes[i].z + calm_sizes[i].w, length(e))); + } + return a; +} + +void vertex() { + // A spyglass disc shows the flat sea. Every vertex goes to one point behind the eye, so the + // clipper drops each triangle before a fragment and no wave is summed. + if (((CAMERA_VISIBLE_LAYERS & 160u) == 32u)) { + VERTEX = vec3(0.0, 0.0, 1.0); + v_param = vec2(0.0); + } else { + vec2 p = VERTEX.xz + CAMERA_POSITION_WORLD.xz; + float spacing = max(UV.x, 0.01); + float amp = shore_swell(ocean_mask(p).g) * ship_calm(p) * wave_scale; + vec3 disp = vec3(0.0); + vec2 group = sea_group(p); + mat2 dwarp; + vec4 warp = sea_warp(p, dwarp); + vec4 fine = sea_fine(p); + for (int i = 0; i < SWELL_DKA.length(); i++) { + vec4 w = SWELL_DKA[i]; + float len = 6.2831853 / length(sea_wavevector(w, SWELL_MOD[i], dwarp)); + vec2 sm = sea_mod(SWELL_MOD[i], SWELL_FINE[i], group, warp.xy, fine); + // Full height at eight grid steps per local wavelength, none at four. A wave the grid + // cannot carry would crawl as the grid follows the eye. + float g = amp * clamp(len / spacing * 0.25 - 1.0, 0.0, 1.0) * sm.x; + float th = w.z * dot(w.xy, p) - SWELL_QW[i].y * csky_time + sm.y; + disp.xz += w.xy * (SWELL_QW[i].x * w.w * g * cos(th)); + disp.y += w.w * g * sin(th); + } + v_param = p; + // Over a harbour ramp the sea rises with the sheet it replaces, up to the quay it meets. + float lift = textureLod(lift_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).r * 4.0; + vec3 world = vec3(p.x + disp.x, disp.y + lift, p.y + disp.z); + VERTEX = (VIEW_MATRIX * vec4(world, 1.0)).xyz; + // A priority level below the lowest base sheet. Every coplanar layer over or beside it + // stays on top, the surf ring and C5's fog-gradient passes included. + VERTEX *= 1.0; + NORMAL = (VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz; + } +} + +void fragment() { + vec2 p = v_param; + vec2 m = ocean_mask(p); + vec3 world = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; + // One-sided like the sheet it replaces: an eye below the surface sees through it. + if (m.r < 0.02 || CAMERA_POSITION_WORLD.y < world.y) { + discard; + } + float zone = textureLod(zone_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).r * 255.0; + if (zone < 254.5 && abs(zone - zone_index) > 0.5) { + discard; + } + // A ship's zone and the shore's swell ramp flatten only the geometry. The normals keep the + // swell and the fine detail, so the water there shades like the open sea. Only the short look + // ramp below calms them. A calm patch would read as an artefact. + float look = shore_look(m.g); + float amp = wave_scale; + vec2 fw = fwidth(p); + float footprint = max(max(fw.x, fw.y), 0.001); + vec3 n = vec3(0.0, 1.0, 0.0); + // The horizontal displacement's slope (columns: along x, along z), for the foam's Jacobian. + // Summed from the fragment's own waves, not the grid's, so the foam reads the same at every + // distance the waves are drawn. + vec2 bx = vec2(0.0); + vec2 bz = vec2(0.0); + // The warp's slope is in each wave's normal, as the vertex stage's fade reads it. The grouping + // and fine fields' slopes are left out: they bend a crest by a few per cent at most. + vec2 group = sea_group(p); + mat2 dwarp; + vec4 warp = sea_warp(p, dwarp); + vec4 fine = sea_fine(p); + // The tables run longest first (EmitWaves checks), and no local length passes WarpStretchMax + // times the wave's own. Once that bound fades out, every later wave fades too. + for (int i = 0; i < SWELL_DKA.length(); i++) { + vec4 w = SWELL_DKA[i]; + float len = 6.2831853 / w.z; + if (len * 1.5 <= footprint * 4.0) { + break; + } + vec2 kv = sea_wavevector(w, SWELL_MOD[i], dwarp); + float fade = smoothstep(4.0, 9.0, 6.2831853 / length(kv) / footprint); + vec2 sm = sea_mod(SWELL_MOD[i], SWELL_FINE[i], group, warp.xy, fine); + float f = amp * fade * sm.x; + float th = w.z * dot(w.xy, p) - SWELL_QW[i].y * csky_time + sm.y; + float ka = w.z * w.w * f; + float s = sin(th); + n.xz -= kv * (w.w * f * cos(th)); + n.y -= SWELL_QW[i].x * ka * s; + float qs = SWELL_QW[i].x * w.w * f * s; + bx += w.xy * (kv.x * qs); + bz += w.xy * (kv.y * qs); + } + // The detail layers run longest first (EmitDetail checks), so the first faded layer ends the sum. + // A layer's height is DetailSlope times its cell times the noise, the same slope at every cell + // size. The sea-state grouping varies it by region. + for (int i = 0; i < DETAIL_L.length(); i++) { + vec4 l = DETAIL_L[i]; + float fade = smoothstep(4.0, 9.0, 2.0 / l.z / footprint); + if (fade <= 0.0) { + break; + } + vec2 side = vec2(-l.y, l.x); + vec2 q = vec2(dot(p, l.xy), dot(p, side)) * l.z - vec2(l.w * csky_time, 0.0); + vec3 nd = detail_noise(q); + float f = amp * fade * 0.08 * clamp(1.0 + dot(DETAIL_G[i], group), 0.2, 1.8); + n.xz -= (l.xy * nd.y + side * nd.z) * f; + } + float fog_amt = csky_fog_amount(world, CAMERA_POSITION_WORLD); + // How fully this fragment shades as the flat sheet does. Wholly at the shore, so no edge shows + // against a coplanar coast tile. Rising with the fog, so the far sea fogs as the sheet does + // instead of showing darker, wavier water through it. + float sheet = max(1.0 - look, smoothstep(0.0, 0.25, fog_amt)); + n = normalize(mix(normalize(n), vec3(0.0, 1.0, 0.0), sheet)); + NORMAL = normalize((VIEW_MATRIX * vec4(n, 0.0)).xyz); + float dist = distance(world.xz, CAMERA_POSITION_WORLD.xz); + vec3 tint = texture(tint_tex, (p - mask_rect.xy) * mask_rect.zw).rgb; + // The open sea takes the texture's mean and the light alone varies it: a tiled texture reads as + // a lattice from the air. The last mip is the mean the GPU samples. At sheet = 1 the texture is + // the sheet's own mapping, so no seam shows where the ocean meets a coast tile. + vec3 tex_mean = textureLod(albedo_tex, vec2(0.5), 16.0).rgb; + vec3 col = mix(tex_mean, csky_sample_albedo(albedo_tex, p / tile_m).rgb, sheet) * tint; + // Foam forms where the swell bunches the surface, its Jacobian below 1, which rides each crest. + // A drifting patch field decides where whitecaps can form and moves each crest's threshold. + float bunch = (1.0 - ((1.0 - bx.x) * (1.0 - bz.y) - bz.x * bx.y)) / FOAM_JAC_SIGMA; + float foam = 0.0; + if (bunch > 0.4) { + vec2 wind = SWELL_DKA[0].xy; + vec2 pw = vec2(dot(p, wind), dot(p, vec2(-wind.y, wind.x))); + float patches = foam_patches(pw); + float crest = smoothstep(1.3, 2.3, bunch + (patches - 0.5) * 1.2) + * smoothstep(0.5, 0.75, patches); + crest *= smoothstep(0.42, 0.62, 0.5 + foam_noise(pw * 0.083333, 65536.0)); + foam = crest; + // Up close a whitecap breaks into patches whose mean is the crest's foam. They fade in with + // the footprint as the detail layers do; their edge widens with it, so none shimmers. + float near = smoothstep(4.0, 9.0, 1.4 / footprint); + if (near > 0.0) { + vec2 q = pw * vec2(0.173611, 0.3125); + vec2 warp = vec2(foam_noise(q, 65536.0), foam_noise(q + vec2(17.0, 9.0), 65536.0)); + float cover = 0.3 * crest * clamp(1.0 + 2.5 * warp.x, 0.0, 2.0); + float s = foam_holes(q * 2.285714 + warp * 1.2 + vec2(31.0, -17.0)); + float hole = 1.3 - 1.6 * cover; + float edge = max(0.04, 1.428571 * footprint); + float lump = smoothstep(hole - edge, hole + edge + 0.35, s) * min(cover * 20.0, 1.0); + foam = mix(crest, lump * 3.333333, near); + } + } + float foam_mix = min(foam * foam_strength, 1.0) * (1.0 - sheet); + col = mix(col, vec3(0.6, 0.65, 0.68), foam_mix); + ALBEDO = col; + METALLIC = 0.0; + SPECULAR = 0.5; + // Lost slope detail turns into roughness, so the far sea keeps a glossy sheen, not a mirror. + // Foam is matte where it covers the water. + ROUGHNESS = mix(mix(mix(rough_near, rough_far, smoothstep(150.0, 4000.0, dist)), 0.25, sheet), 0.6, foam_mix); + FOG = vec4(csky_fog_color_at(CAMERA_POSITION_WORLD), fog_amt); +} diff --git a/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/net.json b/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/net.json new file mode 100644 index 000000000..6b8d7b8e1 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/net.json @@ -0,0 +1,20 @@ +[ + [ + [-450.0, 420.0, -450.0, 45.0], + [450.0, 420.0, 450.0, -135.0], + [450.0, 420.0, -450.0, -45.0], + [-450.0, 420.0, 450.0, 135.0], + [-150.0, 520.0, -450.0, 15.0], + [150.0, 520.0, 450.0, -165.0], + [450.0, 520.0, -150.0, -75.0], + [-450.0, 520.0, 150.0, 105.0], + [150.0, 620.0, -450.0, -15.0], + [-150.0, 620.0, 450.0, 165.0], + [450.0, 620.0, 150.0, -105.0], + [-450.0, 620.0, -150.0, 75.0], + [-150.0, 720.0, -150.0, 45.0], + [150.0, 720.0, 150.0, -135.0], + [150.0, 720.0, -150.0, -45.0], + [-150.0, 720.0, 150.0, 135.0] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/weather.json b/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/weather.json new file mode 100644 index 000000000..dfbda966f --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/C1/PROBE1/zrdr/weather.json @@ -0,0 +1,59 @@ +[ + [ + "CLOUD_COVER", + [ + "TOP", + 2600.0, + "BOTTOM", + 2200.0, + "THICKNESS", + 80.0 + ], + "ZONE1", + [ + "FOG_COLOR", + [0.55, 0.6, 0.7], + "FOG_RANGES", + [600.0, 2400.0], + "FOG_ALTITUDE", + [0.0, 1800.0], + "CLIP_RANGES", + [2.0, 3000.0], + "SUNLIGHT_ACTIVE", + [1], + "SUNLIGHT_ORIENTATION", + [-50.0, 60.0, 0.0], + "SUNLIGHT_DIFFUSE", + [1.1], + "SUNLIGHT_AMBIENT", + [0.3], + "SUNLIGHT_COLOR_DIFFUSE", + [1.0, 1.0, 1.0], + "SUNLIGHT_COLOR_AMBIENT", + [0.9, 0.95, 1.0] + ], + "ZONE2", + [ + "FOG_COLOR", + [0.75, 0.8, 0.9], + "FOG_RANGES", + [900.0, 3600.0], + "FOG_ALTITUDE", + [2000.0, 4000.0], + "CLIP_RANGES", + [2.0, 4500.0], + "SUNLIGHT_ACTIVE", + [1], + "SUNLIGHT_ORIENTATION", + [-20.0, 150.0, 0.0], + "SUNLIGHT_DIFFUSE", + [1.4], + "SUNLIGHT_AMBIENT", + [0.35], + "SUNLIGHT_COLOR_DIFFUSE", + [1.0, 1.0, 1.0], + "SUNLIGHT_COLOR_AMBIENT", + [1.0, 1.0, 1.0] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/C1/texture/manifest.json b/CSVM.Tests/fixtures/synthetic/C1/texture/manifest.json new file mode 100644 index 000000000..d1f055af4 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/C1/texture/manifest.json @@ -0,0 +1,10 @@ +{ + "texture_infos": [ + { "name": "probe_skin", "width": 64, "height": 64, "alpha": "None", "stretch": "None" }, + { "name": "probe_panel", "width": 32, "height": 32, "alpha": "None", "stretch": "None" }, + { "name": "probe_tracer", "width": 16, "height": 8, "alpha": "None", "stretch": "None" }, + { "name": "smoke101", "width": 32, "height": 32, "alpha": "None", "stretch": "None" }, + { "name": "smoke102", "width": 32, "height": 32, "alpha": "None", "stretch": "None" }, + { "name": "smoke103", "width": 32, "height": 32, "alpha": "None", "stretch": "None" } + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/messages.json b/CSVM.Tests/fixtures/synthetic/messages.json new file mode 100644 index 000000000..29bedaf70 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/messages.json @@ -0,0 +1,66 @@ +{ + "language_id": 1033, + "entries": [ + { "key": "MSG_PROBE_PLANE", "id": 1, "value": "Probe Plane" }, + { "key": "MSG_PROBE_GUN", "id": 2, "value": "Probe Gun" }, + { "key": "MSG_PROBE_ROCKET", "id": 3, "value": "Probe Rocket" }, + { "key": "MSG_UNKNOWN", "id": 4, "value": "Unnamed probe" }, + { "key": "MSG_CRASH", "id": 5, "value": "Probe crashed" }, + { "key": "MSG_SHOT_DOWN", "id": 6, "value": "probe shot down" }, + { "key": "MSG_WINGMAN_SHOT_DOWN", "id": 7, "value": "Probe wingman lost" }, + { "key": "MSG_DESTROYED", "id": 8, "value": "probe destroyed" }, + { "key": "MSG_PRESS_AUTOLAND", "id": 9, "value": "Probe dock: press %1" }, + { "key": "MSG_CLICK_AUTOLAND", "id": 10, "value": "Probe dock: click %1" }, + { "key": "MSG_PROBE_FIGHTER", "id": 11, "value": "Probe Fighter" }, + { "key": "MSG_PROBE_TORPEDO", "id": 31, "value": "Probe torpedo" }, + { "key": "MSG_PROBE_HE", "id": 32, "value": "Probe burst rocket" }, + { "key": "MSG_PROBE_CHOKER", "id": 33, "value": "Probe choke rocket" }, + { "key": "MSG_PROBE_FLARE", "id": 34, "value": "Probe flare" }, + { "key": "MSG_PROBE_SONIC", "id": 35, "value": "Probe shock rocket" }, + { "key": "MSG_PROBE_FLASH", "id": 36, "value": "Probe dazzle rocket" }, + { "key": "MSG_PROBE_SEEKER", "id": 37, "value": "Probe homing rocket" }, + { "key": "MSG_PROBE_BEEPER", "id": 38, "value": "Probe marker rocket" }, + { "key": "MSG_PROBE_FLAK", "id": 39, "value": "Probe airburst rocket" }, + { "key": "MSG_PROBE_INCENDIARY", "id": 40, "value": "Probe motor rocket" }, + { "key": "MSG_PROBE_SMOKER", "id": 41, "value": "Probe smoke pot" }, + { "key": "MSG_PROBE_BLAST_ROCKET", "id": 12, "value": "Probe Blast Rocket" }, + { "key": "MSG_TIME_EXPIRED", "id": 13, "value": "Probe clock out" }, + { "key": "MSG_MISSION_LOST", "id": 14, "value": "Probe sortie failed" }, + { "key": "MSG_MP_SUICIDE", "id": 15, "value": "%1 probe self-loss" }, + { "key": "MSG_DESTROYED_BY_X", "id": 16, "value": "probe downed by %1" }, + { "key": "MSG_MP_KILLED_X_TURRET", "id": 53, "value": "Probe turret of %1 scored" }, + { "key": "MSG_ONE_LIFE", "id": 54, "value": "Probe: last life" }, + { "key": "MSG_STATE_GAMEOVER", "id": 55, "value": "Probe match over" }, + { "key": "MSG_MP_ALL_ALONE", "id": 56, "value": "Probe field cleared" }, + { "key": "MSG_MP_REARMED", "id": 57, "value": "Probe pad rearm" }, + { "key": "MSG_X_YOUR", "id": 58, "value": "Our" }, + { "key": "MSG_X_ENEMY", "id": 59, "value": "Rival" }, + { "key": "MSG_MP_YOUR_BASE", "id": 60, "value": "Probe home pad" }, + { "key": "MSG_MP_ENEMY_BASE", "id": 61, "value": "Probe rival pad" }, + { "key": "MSG_MP_YOUR_FLAG_BASE", "id": 62, "value": "Probe pennant on our pad" }, + { "key": "MSG_MP_ENEMY_FLAG_BASE", "id": 63, "value": "Probe pennant on rival pad" }, + { "key": "MSG_FLAG_CAP_BY", "id": 64, "value": "%1 probe pennant lifted by %2" }, + { "key": "MSG_YOUR_FLAG_FLOAT", "id": 65, "value": "Our probe pennant adrift" }, + { "key": "MSG_ENEMY_FLAG_FLOAT", "id": 66, "value": "Rival probe pennant adrift" }, + { "key": "MSG_HOLDS_FLAG", "id": 67, "value": "%1 flies %2 probe pennant" }, + { "key": "MSG_PROBE_BRAND", "id": 68, "value": "Probe Lancer" }, + { "key": "MSG_PROBE_AVENGER", "id": 69, "value": "Probe Bulwark" }, + { "key": "MSG_PROBE_CAL30", "id": 70, "value": "Probe light gun" }, + { "key": "MSG_PROBE_CAL40", "id": 71, "value": "Probe medium gun" }, + { "key": "MSG_PROBE_CAL50", "id": 72, "value": "Probe heavy gun" }, + { "key": "MSG_PROBE_CAL60", "id": 73, "value": "Probe cannon" }, + { "key": "MSG_PROBE_CAL70", "id": 74, "value": "Probe great cannon" }, + { "key": "MSG_PROBE_PYLON_HE", "id": 75, "value": "Probe pylon rocket" }, + { "key": "MSG_PROBE_GUNHIT", "id": 76, "value": "Probe Strike Gun" }, + { "key": "MSG_PROBE_HAWK", "id": 77, "value": "Probe Kite" }, + { "key": "MSG_PROBE_GYRO", "id": 78, "value": "Probe Whirl" }, + { "key": "MSG_PROBE_FURY", "id": 79, "value": "Probe Gale" }, + { "key": "MSG_PROBE_PEACE", "id": 80, "value": "Probe Warden" }, + { "key": "MSG_PROBE_WARHAWK", "id": 81, "value": "Probe Hauler" }, + { "key": "MSG_PROBE_KESTREL", "id": 82, "value": "Probe Heron" }, + { "key": "MSG_PROBE_ZEPTORP", "id": 83, "value": "Probe hull torpedo" }, + { "key": "MSG_TUR_PROBE_REAR_G3", "id": 84, "value": "Probe tail gunner" }, + { "key": "MSG_TUR_PROBE_AA", "id": 85, "value": "Probe ground gun" }, + { "key": "MSG_TUR_PROBE_DORSAL_G3", "id": 86, "value": "Probe dorsal gunner" } + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/planes/boxes.json b/CSVM.Tests/fixtures/synthetic/planes/boxes.json new file mode 100644 index 000000000..f04516917 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/planes/boxes.json @@ -0,0 +1,160 @@ +[ + {"min": [-0.6, -0.6, -4.5], "max": [0.6, 0.6, 4.5], "material": 0}, + {"min": [-0.4, 0.0, -0.7], "max": [0.4, 0.4, 0.7], "material": 2}, + {"min": [-5.0, -0.12, -1.0], "max": [5.0, 0.12, 0.9], "material": 1}, + {"min": [-2.0, 0.0, 3.4], "max": [2.0, 0.1, 4.5], "material": 1}, + {"min": [-0.06, 0.6, 3.5], "max": [0.06, 2.0, 4.5], "material": 1}, + {"min": [-0.4, 0.13, -0.4], "max": [0.4, 0.16, 0.4], "material": 4}, + {"min": [-0.4, 0.61, -0.4], "max": [0.4, 0.64, 0.4], "material": 4}, + {"min": [-1.3, -0.08, -0.05], "max": [1.3, 0.08, 0.05], "material": 2}, + {"min": [-1.4, -1.4, -0.02], "max": [1.4, 1.4, 0.02], "material": 2}, + {"min": [-2.5, -0.12, -0.9], "max": [2.5, 0.12, 0.9], "material": 1}, + {"min": [-0.6, -0.6, -2.0], "max": [0.6, 0.6, 2.0], "material": 0}, + {"min": [-12.0, -14.0, -20.0], "max": [12.0, -2.0, -18.0], "material": 3}, + {"min": [-12.0, -14.0, -17.9], "max": [-6.0, -8.0, -17.5], "material": 4}, + {"min": [6.0, -14.0, -17.9], "max": [12.0, -8.0, -17.5], "material": 4}, + {"min": [-0.5, -0.45, -3.4], "max": [0.5, 0.45, 3.6], "material": 5}, + {"min": [-0.3, 0.0, -0.45], "max": [0.3, 0.35, 0.45], "material": 2}, + {"min": [-6.8, -0.3, -0.9], "max": [6.8, -0.1, 0.5], "material": 5}, + {"min": [-1.9, 0.0, 2.8], "max": [1.9, 0.08, 3.6], "material": 1}, + {"min": [-0.05, 0.0, 2.9], "max": [0.05, 1.2, 3.6], "material": 1}, + {"min": [-0.45, -0.09, -0.3], "max": [0.45, -0.06, 0.3], "material": 4}, + {"min": [-0.35, 0.46, -0.3], "max": [0.35, 0.49, 0.3], "material": 4}, + {"min": [-1.0, -0.06, -0.04], "max": [1.0, 0.06, 0.04], "material": 2}, + {"min": [-1.1, -1.1, -0.02], "max": [1.1, 1.1, 0.02], "material": 2}, + {"min": [-0.5, -0.45, -1.7], "max": [0.5, 0.45, 1.7], "material": 5}, + {"min": [-3.4, -0.3, -0.9], "max": [3.4, -0.1, 0.5], "material": 5}, + {"min": [-10.0, -12.0, -18.0], "max": [10.0, -3.0, -16.0], "material": 3}, + {"min": [-10.0, -12.0, -15.9], "max": [-5.0, -7.0, -15.5], "material": 4}, + {"min": [5.0, -12.0, -15.9], "max": [10.0, -7.0, -15.5], "material": 4}, + {"min": [-0.5, -0.5, -0.001], "max": [0.5, 0.5, 0.001], "material": 4}, + {"min": [-2.0, -5.0, -17.9], "max": [-1.0, -4.0, -17.6], "material": 4}, + {"min": [0.0, -6.0, -17.9], "max": [1.0, -5.0, -17.8], "material": 2}, + {"min": [1.5, -6.0, -17.9], "max": [2.5, -5.0, -17.8], "material": 2}, + {"min": [3.0, -6.0, -17.9], "max": [4.0, -5.0, -17.8], "material": 2}, + {"min": [-0.4, -0.405, -4.42], "max": [0.4, 0.405, 4.68], "material": 6}, + {"min": [-0.24, 0.0, -0.585], "max": [0.24, 0.315, 0.585], "material": 2}, + {"min": [-5.44, -0.27, -1.17], "max": [5.44, -0.09, 0.65], "material": 6}, + {"min": [-1.52, 0.0, 3.64], "max": [1.52, 0.072, 4.68], "material": 1}, + {"min": [-0.04, 0.0, 3.77], "max": [0.04, 1.08, 4.68], "material": 1}, + {"min": [-0.36, -0.081, -0.39], "max": [0.36, -0.054, 0.39], "material": 4}, + {"min": [-0.28, 0.414, -0.39], "max": [0.28, 0.441, 0.39], "material": 4}, + {"min": [-0.8, -0.054, -0.052], "max": [0.8, 0.054, 0.052], "material": 2}, + {"min": [-0.88, -0.99, -0.026], "max": [0.88, 0.99, 0.026], "material": 2}, + {"min": [-0.4, -0.405, -2.21], "max": [0.4, 0.405, 2.21], "material": 6}, + {"min": [-2.72, -0.27, -1.17], "max": [2.72, -0.09, 0.65], "material": 6}, + {"min": [-8.0, -10.8, -23.4], "max": [8.0, -2.7, -20.8], "material": 3}, + {"min": [-8.0, -10.8, -20.67], "max": [-4.0, -6.3, -20.15], "material": 4}, + {"min": [4.0, -10.8, -20.67], "max": [8.0, -6.3, -20.15], "material": 4}, + {"min": [-0.4, -0.45, -0.001], "max": [0.4, 0.45, 0.001], "material": 4}, + {"min": [-0.6, -0.54, -3.91], "max": [0.6, 0.54, 4.14], "material": 7}, + {"min": [-0.36, 0.0, -0.518], "max": [0.36, 0.42, 0.518], "material": 2}, + {"min": [-8.16, -0.36, -1.035], "max": [8.16, -0.12, 0.575], "material": 7}, + {"min": [-2.28, 0.0, 3.22], "max": [2.28, 0.096, 4.14], "material": 1}, + {"min": [-0.06, 0.0, 3.335], "max": [0.06, 1.44, 4.14], "material": 1}, + {"min": [-0.54, -0.108, -0.345], "max": [0.54, -0.072, 0.345], "material": 4}, + {"min": [-0.42, 0.552, -0.345], "max": [0.42, 0.588, 0.345], "material": 4}, + {"min": [-1.2, -0.072, -0.046], "max": [1.2, 0.072, 0.046], "material": 2}, + {"min": [-1.32, -1.32, -0.023], "max": [1.32, 1.32, 0.023], "material": 2}, + {"min": [-0.6, -0.54, -1.955], "max": [0.6, 0.54, 1.955], "material": 7}, + {"min": [-4.08, -0.36, -1.035], "max": [4.08, -0.12, 0.575], "material": 7}, + {"min": [-12.0, -14.4, -20.7], "max": [12.0, -3.6, -18.4], "material": 3}, + {"min": [-12.0, -14.4, -18.285], "max": [-6.0, -8.4, -17.825], "material": 4}, + {"min": [6.0, -14.4, -18.285], "max": [12.0, -8.4, -17.825], "material": 4}, + {"min": [-0.6, -0.6, -0.001], "max": [0.6, 0.6, 0.001], "material": 4}, + {"min": [-0.475, -0.45, -3.06], "max": [0.475, 0.45, 3.24], "material": 8}, + {"min": [-0.285, 0.0, -0.405], "max": [0.285, 0.35, 0.405], "material": 2}, + {"min": [-6.46, -0.3, -0.81], "max": [6.46, -0.1, 0.45], "material": 8}, + {"min": [-1.805, 0.0, 2.52], "max": [1.805, 0.08, 3.24], "material": 1}, + {"min": [-0.0475, 0.0, 2.61], "max": [0.0475, 1.2, 3.24], "material": 1}, + {"min": [-0.4275, -0.09, -0.27], "max": [0.4275, -0.06, 0.27], "material": 4}, + {"min": [-0.3325, 0.46, -0.27], "max": [0.3325, 0.49, 0.27], "material": 4}, + {"min": [-0.95, -0.06, -0.036], "max": [0.95, 0.06, 0.036], "material": 2}, + {"min": [-1.045, -1.1, -0.018], "max": [1.045, 1.1, 0.018], "material": 2}, + {"min": [-0.475, -0.45, -1.53], "max": [0.475, 0.45, 1.53], "material": 8}, + {"min": [-3.23, -0.3, -0.81], "max": [3.23, -0.1, 0.45], "material": 8}, + {"min": [-9.5, -12.0, -16.2], "max": [9.5, -3.0, -14.4], "material": 3}, + {"min": [-9.5, -12.0, -14.31], "max": [-4.75, -7.0, -13.95], "material": 4}, + {"min": [4.75, -12.0, -14.31], "max": [9.5, -7.0, -13.95], "material": 4}, + {"min": [-0.475, -0.5, -0.0009], "max": [0.475, 0.5, 0.0009], "material": 4}, + {"min": [-0.35, -0.5175, -2.72], "max": [0.35, 0.5175, 2.88], "material": 9}, + {"min": [-0.21, 0.0, -0.36], "max": [0.21, 0.4025, 0.36], "material": 2}, + {"min": [-4.76, -0.345, -0.72], "max": [4.76, -0.115, 0.4], "material": 9}, + {"min": [-1.33, 0.0, 2.24], "max": [1.33, 0.092, 2.88], "material": 1}, + {"min": [-0.035, 0.0, 2.32], "max": [0.035, 1.38, 2.88], "material": 1}, + {"min": [-0.315, -0.1035, -0.24], "max": [0.315, -0.069, 0.24], "material": 4}, + {"min": [-0.245, 0.529, -0.24], "max": [0.245, 0.5635, 0.24], "material": 4}, + {"min": [-0.7, -0.069, -0.032], "max": [0.7, 0.069, 0.032], "material": 2}, + {"min": [-0.77, -1.265, -0.016], "max": [0.77, 1.265, 0.016], "material": 2}, + {"min": [-0.35, -0.5175, -1.36], "max": [0.35, 0.5175, 1.36], "material": 9}, + {"min": [-2.38, -0.345, -0.72], "max": [2.38, -0.115, 0.4], "material": 9}, + {"min": [-7.0, -13.8, -14.4], "max": [7.0, -3.45, -12.8], "material": 3}, + {"min": [-7.0, -13.8, -12.72], "max": [-3.5, -8.05, -12.4], "material": 4}, + {"min": [3.5, -13.8, -12.72], "max": [7.0, -8.05, -12.4], "material": 4}, + {"min": [-0.35, -0.575, -0.0008], "max": [0.35, 0.575, 0.0008], "material": 4}, + {"min": [-0.525, -0.4275, -3.74], "max": [0.525, 0.4275, 3.96], "material": 10}, + {"min": [-0.315, 0.0, -0.495], "max": [0.315, 0.3325, 0.495], "material": 2}, + {"min": [-7.14, -0.285, -0.99], "max": [7.14, -0.095, 0.55], "material": 10}, + {"min": [-1.995, 0.0, 3.08], "max": [1.995, 0.076, 3.96], "material": 1}, + {"min": [-0.0525, 0.0, 3.19], "max": [0.0525, 1.14, 3.96], "material": 1}, + {"min": [-0.4725, -0.0855, -0.33], "max": [0.4725, -0.057, 0.33], "material": 4}, + {"min": [-0.3675, 0.437, -0.33], "max": [0.3675, 0.4655, 0.33], "material": 4}, + {"min": [-1.05, -0.057, -0.044], "max": [1.05, 0.057, 0.044], "material": 2}, + {"min": [-1.155, -1.045, -0.022], "max": [1.155, 1.045, 0.022], "material": 2}, + {"min": [-0.525, -0.4275, -1.87], "max": [0.525, 0.4275, 1.87], "material": 10}, + {"min": [-3.57, -0.285, -0.99], "max": [3.57, -0.095, 0.55], "material": 10}, + {"min": [-10.5, -11.4, -19.8], "max": [10.5, -2.85, -17.6], "material": 3}, + {"min": [-10.5, -11.4, -17.49], "max": [-5.25, -6.65, -17.05], "material": 4}, + {"min": [5.25, -11.4, -17.49], "max": [10.5, -6.65, -17.05], "material": 4}, + {"min": [-0.525, -0.475, -0.0011], "max": [0.525, 0.475, 0.0011], "material": 4}, + {"min": [-0.575, -0.4725, -3.23], "max": [0.575, 0.4725, 3.42], "material": 11}, + {"min": [-0.345, 0.0, -0.4275], "max": [0.345, 0.3675, 0.4275], "material": 2}, + {"min": [-7.82, -0.315, -0.855], "max": [7.82, -0.105, 0.475], "material": 11}, + {"min": [-2.185, 0.0, 2.66], "max": [2.185, 0.084, 3.42], "material": 1}, + {"min": [-0.0575, 0.0, 2.755], "max": [0.0575, 1.26, 3.42], "material": 1}, + {"min": [-0.5175, -0.0945, -0.285], "max": [0.5175, -0.063, 0.285], "material": 4}, + {"min": [-0.4025, 0.483, -0.285], "max": [0.4025, 0.5145, 0.285], "material": 4}, + {"min": [-1.15, -0.063, -0.038], "max": [1.15, 0.063, 0.038], "material": 2}, + {"min": [-1.265, -1.155, -0.019], "max": [1.265, 1.155, 0.019], "material": 2}, + {"min": [-0.575, -0.4725, -1.615], "max": [0.575, 0.4725, 1.615], "material": 11}, + {"min": [-3.91, -0.315, -0.855], "max": [3.91, -0.105, 0.475], "material": 11}, + {"min": [-11.5, -12.6, -17.1], "max": [11.5, -3.15, -15.2], "material": 3}, + {"min": [-11.5, -12.6, -15.105], "max": [-5.75, -7.35, -14.725], "material": 4}, + {"min": [5.75, -12.6, -15.105], "max": [11.5, -7.35, -14.725], "material": 4}, + {"min": [-0.575, -0.525, -0.001], "max": [0.575, 0.525, 0.001], "material": 4}, + {"min": [-0.65, -0.54, -4.25], "max": [0.65, 0.54, 4.5], "material": 12}, + {"min": [-0.39, 0.0, -0.5625], "max": [0.39, 0.42, 0.5625], "material": 2}, + {"min": [-8.84, -0.36, -1.125], "max": [8.84, -0.12, 0.625], "material": 12}, + {"min": [-2.47, 0.0, 3.5], "max": [2.47, 0.096, 4.5], "material": 1}, + {"min": [-0.065, 0.0, 3.625], "max": [0.065, 1.44, 4.5], "material": 1}, + {"min": [-0.585, -0.108, -0.375], "max": [0.585, -0.072, 0.375], "material": 4}, + {"min": [-0.455, 0.552, -0.375], "max": [0.455, 0.588, 0.375], "material": 4}, + {"min": [-1.3, -0.072, -0.05], "max": [1.3, 0.072, 0.05], "material": 2}, + {"min": [-1.43, -1.32, -0.025], "max": [1.43, 1.32, 0.025], "material": 2}, + {"min": [-0.65, -0.54, -2.125], "max": [0.65, 0.54, 2.125], "material": 12}, + {"min": [-4.42, -0.36, -1.125], "max": [4.42, -0.12, 0.625], "material": 12}, + {"min": [-13.0, -14.4, -22.5], "max": [13.0, -3.6, -20.0], "material": 3}, + {"min": [-13.0, -14.4, -19.875], "max": [-6.5, -8.4, -19.375], "material": 4}, + {"min": [6.5, -14.4, -19.875], "max": [13.0, -8.4, -19.375], "material": 4}, + {"min": [-0.65, -0.6, -0.0012], "max": [0.65, 0.6, 0.0012], "material": 4}, + {"min": [-0.55, -0.495, -3.91], "max": [0.55, 0.495, 4.14], "material": 13}, + {"min": [-0.33, 0.0, -0.5175], "max": [0.33, 0.385, 0.5175], "material": 2}, + {"min": [-7.48, -0.33, -1.035], "max": [7.48, -0.11, 0.575], "material": 13}, + {"min": [-2.09, 0.0, 3.22], "max": [2.09, 0.088, 4.14], "material": 1}, + {"min": [-0.055, 0.0, 3.335], "max": [0.055, 1.32, 4.14], "material": 1}, + {"min": [-0.495, -0.099, -0.345], "max": [0.495, -0.066, 0.345], "material": 4}, + {"min": [-0.385, 0.506, -0.345], "max": [0.385, 0.539, 0.345], "material": 4}, + {"min": [-1.1, -0.066, -0.046], "max": [1.1, 0.066, 0.046], "material": 2}, + {"min": [-1.21, -1.21, -0.023], "max": [1.21, 1.21, 0.023], "material": 2}, + {"min": [-0.55, -0.495, -1.955], "max": [0.55, 0.495, 1.955], "material": 13}, + {"min": [-3.74, -0.33, -1.035], "max": [3.74, -0.11, 0.575], "material": 13}, + {"min": [-11.0, -13.2, -20.7], "max": [11.0, -3.3, -18.4], "material": 3}, + {"min": [-11.0, -13.2, -18.285], "max": [-5.5, -7.7, -17.825], "material": 4}, + {"min": [5.5, -13.2, -18.285], "max": [11.0, -7.7, -17.825], "material": 4}, + {"min": [-0.55, -0.55, -0.0012], "max": [0.55, 0.55, 0.0012], "material": 4}, + {"min": [-3.4, -0.02, -0.35], "max": [3.4, 0.02, 0.35], "material": 2}, + {"min": [-0.36, -0.08, -0.36], "max": [0.36, 0.08, 0.36], "material": 4}, + {"min": [-0.05, -0.05, -1.0], "max": [0.05, 0.05, 0.0], "material": 2}, + {"min": [-0.36, -0.08, -0.36], "max": [0.36, 0.08, 0.36], "material": 4}, + {"min": [-0.05, -0.05, -1.0], "max": [0.05, 0.05, 0.0], "material": 2} +] diff --git a/CSVM.Tests/fixtures/synthetic/planes/materials.json b/CSVM.Tests/fixtures/synthetic/planes/materials.json new file mode 100644 index 000000000..bcc16fcc1 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/planes/materials.json @@ -0,0 +1,16 @@ +[ + {"Textured": {"texture": "probe_skin", "soil": "Default"}}, + {"Colored": {"color": {"r": 150.0, "g": 156.0, "b": 166.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 40.0, "g": 40.0, "b": 44.0}, "alpha": 255.0, "soil": "Default"}}, + {"Textured": {"texture": "probe_panel", "soil": "Default"}}, + {"Colored": {"color": {"r": 170.0, "g": 60.0, "b": 40.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 92.0, "g": 108.0, "b": 70.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 52.0, "g": 70.0, "b": 128.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 178.0, "g": 152.0, "b": 98.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 150.0, "g": 40.0, "b": 46.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 206.0, "g": 176.0, "b": 60.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 60.0, "g": 132.0, "b": 120.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 120.0, "g": 120.0, "b": 150.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 84.0, "g": 70.0, "b": 52.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 110.0, "g": 70.0, "b": 136.0}, "alpha": 255.0, "soil": "Default"}} +] diff --git a/CSVM.Tests/fixtures/synthetic/planes/nodes.json b/CSVM.Tests/fixtures/synthetic/planes/nodes.json new file mode 100644 index 000000000..5b376b703 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/planes/nodes.json @@ -0,0 +1,11168 @@ +[ + { + "Object3d": { + "name": "probe_plane", + "mesh_index": -1, + "children": [ + 1, + 47 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 2, + 18, + 40, + 43 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 3 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_lod", + "mesh_index": -1, + "children": [ + 4, + 6, + 7, + 8, + 9 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1200.0 + } + } + }, + { + "Object3d": { + "name": "probe_fuselage", + "mesh_index": 0, + "children": [ + 5 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_canopy", + "mesh_index": 1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.6, + "z": -0.5 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_wings", + "mesh_index": 2, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_tailplane", + "mesh_index": 3, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fin", + "mesh_index": 4, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": 3.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": -3.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": -2.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": 2.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": -4.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 5, + "children": [], + "transformation": { + "translation": { + "x": 4.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 6, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.5 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 6, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 3.5 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.8, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.8, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.6, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.6, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.4, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.4, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.2, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.2, + "y": -0.05, + "z": -1.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.4, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.4, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.6, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.6, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.8, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.8, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.0, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.0, + "y": -0.3, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.95, + "z": -0.4 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.65, + "y": 0.1, + "z": -3.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.65, + "y": 0.1, + "z": -3.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.6, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 41, + 42 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 7, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 8, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.65 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 44, + 45, + 46 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 10, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 9, + "children": [], + "transformation": { + "translation": { + "x": -2.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 9, + "children": [], + "transformation": { + "translation": { + "x": 2.5, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 48, + 49, + 50, + 105, + 106 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_dash", + "mesh_index": 11, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 12, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 13, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_pfighter", + "mesh_index": -1, + "children": [ + 52, + 101 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 53, + 72, + 94, + 97 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 54 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_fighter_lod", + "mesh_index": -1, + "children": [ + 55, + 57, + 60, + 61, + 62, + 63 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_fighter_fuselage", + "mesh_index": 14, + "children": [ + 56 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_canopy", + "mesh_index": 15, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.45, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_wings", + "mesh_index": 16, + "children": [ + 58, + 59 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 28, + "children": [], + "transformation": { + "translation": { + "x": -6.85, + "y": -0.2, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 28, + "children": [], + "transformation": { + "translation": { + "x": 6.85, + "y": -0.2, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_tailplane", + "mesh_index": 17, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_fin1", + "mesh_index": 18, + "children": [], + "transformation": { + "translation": { + "x": -1.85, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_fin2", + "mesh_index": 18, + "children": [], + "transformation": { + "translation": { + "x": 1.85, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": 4.8, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": -4.8, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": -2.6, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": 2.6, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": -6.0, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 19, + "children": [], + "transformation": { + "translation": { + "x": 6.0, + "y": 0.0, + "z": -0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 20, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 20, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.3, + "y": 0.2, + "z": -3.45 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.3, + "y": 0.2, + "z": -3.45 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.55, + "y": -0.2, + "z": -2.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.55, + "y": -0.2, + "z": -2.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.9, + "y": -0.2, + "z": -0.95 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.9, + "y": -0.2, + "z": -0.95 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.7, + "y": -0.2, + "z": -0.95 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.7, + "y": -0.2, + "z": -0.95 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.6, + "y": -0.45, + "z": -0.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.7, + "z": -0.15 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.4, + "y": 0.0, + "z": -2.9 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.4, + "y": 0.0, + "z": -2.9 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.3, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 95, + 96 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 21, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.5 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 22, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.55 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 98, + 99, + 100 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 23, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.6 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 24, + "children": [], + "transformation": { + "translation": { + "x": -3.4, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 24, + "children": [], + "transformation": { + "translation": { + "x": 3.4, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 102, + 103, + 104 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fighter_dash", + "mesh_index": 25, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 26, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 27, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_lamp_on", + "mesh_index": 29, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_holes", + "mesh_index": -1, + "children": [ + 107, + 108, + 109 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "bul9a", + "mesh_index": 30, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "bul9b", + "mesh_index": 31, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "bul9c", + "mesh_index": 32, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_fbrand", + "mesh_index": -1, + "children": [ + 111, + 160 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 112, + 131, + 153, + 156, + 547 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 113 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_brand_lod", + "mesh_index": -1, + "children": [ + 114, + 116, + 119, + 120, + 121, + 122 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_brand_fuselage", + "mesh_index": 33, + "children": [ + 115 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_canopy", + "mesh_index": 34, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.405, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_wings", + "mesh_index": 35, + "children": [ + 117, + 118 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 47, + "children": [], + "transformation": { + "translation": { + "x": -5.48, + "y": -0.18, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 47, + "children": [], + "transformation": { + "translation": { + "x": 5.48, + "y": -0.18, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_tailplane", + "mesh_index": 36, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_fin1", + "mesh_index": 37, + "children": [], + "transformation": { + "translation": { + "x": -1.48, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_fin2", + "mesh_index": 37, + "children": [], + "transformation": { + "translation": { + "x": 1.48, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": 3.84, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": -3.84, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": -2.08, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": 2.08, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": -4.8, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 38, + "children": [], + "transformation": { + "translation": { + "x": 4.8, + "y": 0.0, + "z": -0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 39, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.38 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 39, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 3.38 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.24, + "y": 0.18, + "z": -4.485 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.24, + "y": 0.18, + "z": -4.485 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.44, + "y": -0.18, + "z": -3.38 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.44, + "y": -0.18, + "z": -3.38 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.52, + "y": -0.18, + "z": -1.235 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.52, + "y": -0.18, + "z": -1.235 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.96, + "y": -0.18, + "z": -1.235 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.96, + "y": -0.18, + "z": -1.235 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.48, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.48, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.68, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.68, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.88, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.88, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.08, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.08, + "y": -0.405, + "z": -0.13 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.26 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.63, + "z": -0.195 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.32, + "y": 0.0, + "z": -3.77 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.32, + "y": 0.0, + "z": -3.77 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.17, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 154, + 155 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 40, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.55 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 41, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.615 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 157, + 158, + 159 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 42, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 43, + "children": [], + "transformation": { + "translation": { + "x": -2.72, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 43, + "children": [], + "transformation": { + "translation": { + "x": 2.72, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 161, + 162, + 163 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_dash", + "mesh_index": 44, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 45, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 46, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_avenger", + "mesh_index": -1, + "children": [ + 165, + 214 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 166, + 185, + 207, + 210 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 167 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_avenger_lod", + "mesh_index": -1, + "children": [ + 168, + 170, + 173, + 174, + 175, + 176 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_avenger_fuselage", + "mesh_index": 48, + "children": [ + 169 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_canopy", + "mesh_index": 49, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.54, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_wings", + "mesh_index": 50, + "children": [ + 171, + 172 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 62, + "children": [], + "transformation": { + "translation": { + "x": -8.22, + "y": -0.24, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 62, + "children": [], + "transformation": { + "translation": { + "x": 8.22, + "y": -0.24, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_tailplane", + "mesh_index": 51, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_fin1", + "mesh_index": 52, + "children": [], + "transformation": { + "translation": { + "x": -2.22, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_fin2", + "mesh_index": 52, + "children": [], + "transformation": { + "translation": { + "x": 2.22, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": 5.76, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": -5.76, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": -3.12, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": 3.12, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": -7.2, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 53, + "children": [], + "transformation": { + "translation": { + "x": 7.2, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 54, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 54, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.36, + "y": 0.24, + "z": -3.968 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.36, + "y": 0.24, + "z": -3.968 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.66, + "y": -0.24, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.66, + "y": -0.24, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.28, + "y": -0.24, + "z": -1.092 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.28, + "y": -0.24, + "z": -1.092 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.44, + "y": -0.24, + "z": -1.092 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.44, + "y": -0.24, + "z": -1.092 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -6.72, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 6.72, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.52, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.52, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.32, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.32, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.12, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.12, + "y": -0.54, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.84, + "z": -0.172 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.48, + "y": 0.0, + "z": -3.335 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.48, + "y": 0.0, + "z": -3.335 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.56, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 208, + 209 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 55, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 56, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.082 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 211, + 212, + 213 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 57, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.84 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 58, + "children": [], + "transformation": { + "translation": { + "x": -4.08, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 58, + "children": [], + "transformation": { + "translation": { + "x": 4.08, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 215, + 216, + 217 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_avenger_dash", + "mesh_index": 59, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 60, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 61, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_bhawk", + "mesh_index": -1, + "children": [ + 219, + 268 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 220, + 239, + 261, + 264 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 221 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_hawk_lod", + "mesh_index": -1, + "children": [ + 222, + 224, + 227, + 228, + 229, + 230 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_hawk_fuselage", + "mesh_index": 63, + "children": [ + 223 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_canopy", + "mesh_index": 64, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.45, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_wings", + "mesh_index": 65, + "children": [ + 225, + 226 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 77, + "children": [], + "transformation": { + "translation": { + "x": -6.5075, + "y": -0.2, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 77, + "children": [], + "transformation": { + "translation": { + "x": 6.5075, + "y": -0.2, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_tailplane", + "mesh_index": 66, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_fin1", + "mesh_index": 67, + "children": [], + "transformation": { + "translation": { + "x": -1.7575, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_fin2", + "mesh_index": 67, + "children": [], + "transformation": { + "translation": { + "x": 1.7575, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 231, + 232, + 233, + 234, + 235, + 236, + 237, + 238 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": 4.56, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": -4.56, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": -2.47, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": 2.47, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": -5.7, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 68, + "children": [], + "transformation": { + "translation": { + "x": 5.7, + "y": 0.0, + "z": -0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 69, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.34 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 69, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.34 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 240, + 241, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.285, + "y": 0.2, + "z": -3.105 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.285, + "y": 0.2, + "z": -3.105 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.5225, + "y": -0.2, + "z": -2.34 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.5225, + "y": -0.2, + "z": -2.34 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.805, + "y": -0.2, + "z": -0.855 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.805, + "y": -0.2, + "z": -0.855 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.515, + "y": -0.2, + "z": -0.855 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.515, + "y": -0.2, + "z": -0.855 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.32, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.32, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.37, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.37, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.42, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.42, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.47, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.47, + "y": -0.45, + "z": -0.09 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.18 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.7, + "z": -0.135 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.38, + "y": 0.0, + "z": -2.61 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.38, + "y": 0.0, + "z": -2.61 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.3, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 262, + 263 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 70, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.15 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 71, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.195 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 265, + 266, + 267 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 72, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.44 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 73, + "children": [], + "transformation": { + "translation": { + "x": -3.23, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 73, + "children": [], + "transformation": { + "translation": { + "x": 3.23, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 269, + 270, + 271 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_hawk_dash", + "mesh_index": 74, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 75, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 76, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_autogyro", + "mesh_index": -1, + "children": [ + 273, + 322 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 274, + 293, + 315, + 318 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 275 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_gyro_lod", + "mesh_index": -1, + "children": [ + 276, + 278, + 281, + 282, + 283, + 284 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_gyro_fuselage", + "mesh_index": 78, + "children": [ + 277 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_canopy", + "mesh_index": 79, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.5175, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_wings", + "mesh_index": 80, + "children": [ + 279, + 280 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 92, + "children": [], + "transformation": { + "translation": { + "x": -4.795, + "y": -0.23, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 92, + "children": [], + "transformation": { + "translation": { + "x": 4.795, + "y": -0.23, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_tailplane", + "mesh_index": 81, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_fin1", + "mesh_index": 82, + "children": [], + "transformation": { + "translation": { + "x": -1.295, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_fin2", + "mesh_index": 82, + "children": [], + "transformation": { + "translation": { + "x": 1.295, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": 3.36, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": -3.36, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": -1.82, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": 1.82, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": -4.2, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 83, + "children": [], + "transformation": { + "translation": { + "x": 4.2, + "y": 0.0, + "z": -0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 84, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 84, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 294, + 295, + 296, + 297, + 298, + 299, + 300, + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 310, + 311, + 312, + 313, + 314 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.21, + "y": 0.23, + "z": -2.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.21, + "y": 0.23, + "z": -2.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.385, + "y": -0.23, + "z": -2.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.385, + "y": -0.23, + "z": -2.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.33, + "y": -0.23, + "z": -0.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.33, + "y": -0.23, + "z": -0.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.59, + "y": -0.23, + "z": -0.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.59, + "y": -0.23, + "z": -0.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.92, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.92, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.22, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.22, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.52, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.52, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.82, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.82, + "y": -0.5175, + "z": -0.08 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.16 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.805, + "z": -0.12 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.28, + "y": 0.0, + "z": -2.32 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.28, + "y": 0.0, + "z": -2.32 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.495, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 316, + 317, + 542 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 85, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.8 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 86, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.84 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 319, + 320, + 321 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 87, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.28 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 88, + "children": [], + "transformation": { + "translation": { + "x": -2.38, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 88, + "children": [], + "transformation": { + "translation": { + "x": 2.38, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 323, + 324, + 325 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_gyro_dash", + "mesh_index": 89, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 90, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 91, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_fury", + "mesh_index": -1, + "children": [ + 327, + 376 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 328, + 347, + 369, + 372 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 329 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_fury_lod", + "mesh_index": -1, + "children": [ + 330, + 332, + 335, + 336, + 337, + 338 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_fury_fuselage", + "mesh_index": 93, + "children": [ + 331 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_canopy", + "mesh_index": 94, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.4275, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_wings", + "mesh_index": 95, + "children": [ + 333, + 334 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 107, + "children": [], + "transformation": { + "translation": { + "x": -7.1925, + "y": -0.19, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 107, + "children": [], + "transformation": { + "translation": { + "x": 7.1925, + "y": -0.19, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_tailplane", + "mesh_index": 96, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_fin1", + "mesh_index": 97, + "children": [], + "transformation": { + "translation": { + "x": -1.9425, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_fin2", + "mesh_index": 97, + "children": [], + "transformation": { + "translation": { + "x": 1.9425, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": 5.04, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": -5.04, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": -2.73, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": 2.73, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": -6.3, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 98, + "children": [], + "transformation": { + "translation": { + "x": 6.3, + "y": 0.0, + "z": -0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 99, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.86 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 99, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.86 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.315, + "y": 0.19, + "z": -3.795 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.315, + "y": 0.19, + "z": -3.795 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.5775, + "y": -0.19, + "z": -2.86 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.5775, + "y": -0.19, + "z": -2.86 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -1.995, + "y": -0.19, + "z": -1.045 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 1.995, + "y": -0.19, + "z": -1.045 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.885, + "y": -0.19, + "z": -1.045 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.885, + "y": -0.19, + "z": -1.045 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.88, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.88, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.83, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.83, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.78, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.78, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.73, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.73, + "y": -0.4275, + "z": -0.11 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.22 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.665, + "z": -0.165 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.42, + "y": 0.0, + "z": -3.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.42, + "y": 0.0, + "z": -3.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.235, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 370, + 371 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 100, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.85 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 101, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.905 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 373, + 374, + 375 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 102, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 103, + "children": [], + "transformation": { + "translation": { + "x": -3.57, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 103, + "children": [], + "transformation": { + "translation": { + "x": 3.57, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 377, + 378, + 379 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_fury_dash", + "mesh_index": 104, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 105, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 106, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_peacemaker", + "mesh_index": -1, + "children": [ + 381, + 430 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 382, + 401, + 423, + 426 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 383 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_peace_lod", + "mesh_index": -1, + "children": [ + 384, + 386, + 389, + 390, + 391, + 392 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_peace_fuselage", + "mesh_index": 108, + "children": [ + 385 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_canopy", + "mesh_index": 109, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.4725, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_wings", + "mesh_index": 110, + "children": [ + 387, + 388 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 122, + "children": [], + "transformation": { + "translation": { + "x": -7.8775, + "y": -0.21, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 122, + "children": [], + "transformation": { + "translation": { + "x": 7.8775, + "y": -0.21, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_tailplane", + "mesh_index": 111, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_fin1", + "mesh_index": 112, + "children": [], + "transformation": { + "translation": { + "x": -2.1275, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_fin2", + "mesh_index": 112, + "children": [], + "transformation": { + "translation": { + "x": 2.1275, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 400 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": 5.52, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": -5.52, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": -2.99, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": 2.99, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": -6.9, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 113, + "children": [], + "transformation": { + "translation": { + "x": 6.9, + "y": 0.0, + "z": -0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 114, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.47 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 114, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.47 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 402, + 403, + 404, + 405, + 406, + 407, + 408, + 409, + 410, + 411, + 412, + 413, + 414, + 415, + 416, + 417, + 418, + 419, + 420, + 421, + 422 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.345, + "y": 0.21, + "z": -3.2775 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.345, + "y": 0.21, + "z": -3.2775 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.6325, + "y": -0.21, + "z": -2.47 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.6325, + "y": -0.21, + "z": -2.47 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.185, + "y": -0.21, + "z": -0.9025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.185, + "y": -0.21, + "z": -0.9025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.255, + "y": -0.21, + "z": -0.9025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.255, + "y": -0.21, + "z": -0.9025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -6.44, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 6.44, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.29, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.29, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.14, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.14, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.99, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.99, + "y": -0.4725, + "z": -0.095 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.19 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.735, + "z": -0.1425 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.46, + "y": 0.0, + "z": -2.755 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.46, + "y": 0.0, + "z": -2.755 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.365, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 424, + 425 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 115, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.325 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 116, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.3725 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 427, + 428, + 429 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 117, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.52 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 118, + "children": [], + "transformation": { + "translation": { + "x": -3.91, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 118, + "children": [], + "transformation": { + "translation": { + "x": 3.91, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 431, + 432, + 433 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_peace_dash", + "mesh_index": 119, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 120, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 121, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_warhawk", + "mesh_index": -1, + "children": [ + 435, + 484 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 436, + 455, + 477, + 480 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 437 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_warhawk_lod", + "mesh_index": -1, + "children": [ + 438, + 440, + 443, + 444, + 445, + 446 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_fuselage", + "mesh_index": 123, + "children": [ + 439 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_canopy", + "mesh_index": 124, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.54, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_wings", + "mesh_index": 125, + "children": [ + 441, + 442 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 137, + "children": [], + "transformation": { + "translation": { + "x": -8.905, + "y": -0.24, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 137, + "children": [], + "transformation": { + "translation": { + "x": 8.905, + "y": -0.24, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_tailplane", + "mesh_index": 126, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_fin1", + "mesh_index": 127, + "children": [], + "transformation": { + "translation": { + "x": -2.405, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_fin2", + "mesh_index": 127, + "children": [], + "transformation": { + "translation": { + "x": 2.405, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 447, + 448, + 449, + 450, + 451, + 452, + 453, + 454 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": 6.24, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": -6.24, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": -3.38, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": 3.38, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": -7.8, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 128, + "children": [], + "transformation": { + "translation": { + "x": 7.8, + "y": 0.0, + "z": -0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 129, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -3.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 129, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 3.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 456, + 457, + 458, + 459, + 460, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 475, + 476 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.39, + "y": 0.24, + "z": -4.3125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.39, + "y": 0.24, + "z": -4.3125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.715, + "y": -0.24, + "z": -3.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.715, + "y": -0.24, + "z": -3.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.47, + "y": -0.24, + "z": -1.1875 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.47, + "y": -0.24, + "z": -1.1875 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.81, + "y": -0.24, + "z": -1.1875 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.81, + "y": -0.24, + "z": -1.1875 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -7.28, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 7.28, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.98, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.98, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.68, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.68, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.38, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.38, + "y": -0.54, + "z": -0.125 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.25 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.84, + "z": -0.1875 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.52, + "y": 0.0, + "z": -3.625 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.52, + "y": 0.0, + "z": -3.625 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.56, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 478, + 479 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 130, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.375 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 131, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.4375 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 481, + 482, + 483 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 132, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 133, + "children": [], + "transformation": { + "translation": { + "x": -4.42, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 133, + "children": [], + "transformation": { + "translation": { + "x": 4.42, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 485, + 486, + 487 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_warhawk_dash", + "mesh_index": 134, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 135, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 136, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_kestrel", + "mesh_index": -1, + "children": [ + 489, + 538 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "geometry", + "mesh_index": -1, + "children": [ + 490, + 509, + 531, + 534, + 543 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "healthy", + "mesh_index": -1, + "children": [ + 491 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Lod": { + "name": "probe_kestrel_lod", + "mesh_index": -1, + "children": [ + 492, + 494, + 497, + 498, + 499, + 500 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "range": { + "min": 0.0, + "max": 1500.0 + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_fuselage", + "mesh_index": 138, + "children": [ + 493 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_canopy", + "mesh_index": 139, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.495, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_wings", + "mesh_index": 140, + "children": [ + 495, + 496 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare1", + "mesh_index": 152, + "children": [], + "transformation": { + "translation": { + "x": -7.535, + "y": -0.22, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "wing_flare2", + "mesh_index": 152, + "children": [], + "transformation": { + "translation": { + "x": 7.535, + "y": -0.22, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_tailplane", + "mesh_index": 141, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_fin1", + "mesh_index": 142, + "children": [], + "transformation": { + "translation": { + "x": -2.035, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_fin2", + "mesh_index": 142, + "children": [], + "transformation": { + "translation": { + "x": 2.035, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "player_damage_on", + "mesh_index": -1, + "children": [ + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp1", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": 5.28, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp2", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": -5.28, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp3", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": -2.86, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp4", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": 2.86, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp5", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": -6.6, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp6", + "mesh_index": 143, + "children": [], + "transformation": { + "translation": { + "x": 6.6, + "y": 0.0, + "z": -0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp7", + "mesh_index": 144, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pdp8", + "mesh_index": 144, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "markers", + "mesh_index": -1, + "children": [ + 510, + 511, + 512, + 513, + 514, + 515, + 516, + 517, + 518, + 519, + 520, + 521, + 522, + 523, + 524, + 525, + 526, + 527, + 528, + 529, + 530 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.33, + "y": 0.22, + "z": -3.9675 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.33, + "y": 0.22, + "z": -3.9675 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.605, + "y": -0.22, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.605, + "y": -0.22, + "z": -2.99 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.09, + "y": -0.22, + "z": -1.0925 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.09, + "y": -0.22, + "z": -1.0925 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -4.07, + "y": -0.22, + "z": -1.0925 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "firepoint8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 4.07, + "y": -0.22, + "z": -1.0925 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -6.16, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 6.16, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon3", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -5.06, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon4", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 5.06, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon5", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -3.96, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon6", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 3.96, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon7", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -2.86, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pylon8", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 2.86, + "y": -0.495, + "z": -0.115 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "target", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.23 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit_camera", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.77, + "z": -0.1725 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust1", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": -0.44, + "y": 0.0, + "z": -3.335 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "exhaust2", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.44, + "y": 0.0, + "z": -3.335 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "ground_level", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": -1.43, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "dontmove", + "mesh_index": -1, + "children": [ + 532, + 533 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "staticprop1", + "mesh_index": 145, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.025 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "prop1", + "mesh_index": 146, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -4.0825 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "destroyed", + "mesh_index": -1, + "children": [ + 535, + 536, + 537 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece1", + "mesh_index": 147, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.84 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece2", + "mesh_index": 148, + "children": [], + "transformation": { + "translation": { + "x": -3.74, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "piece3", + "mesh_index": 148, + "children": [], + "transformation": { + "translation": { + "x": 3.74, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "cockpit1", + "mesh_index": -1, + "children": [ + 539, + 540, + 541 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_dash", + "mesh_index": 149, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp4", + "mesh_index": 150, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pcdp6", + "mesh_index": 151, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "rotor1", + "mesh_index": 153, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 1.6, + "z": 0.2 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_kestrel_turret1", + "mesh_index": -1, + "children": [ + 544 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.66, + "z": 2.76 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pturret", + "mesh_index": 154, + "children": [ + 545 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pgun", + "mesh_index": 155, + "children": [ + 546 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.12, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pfirepoint", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "probe_brand_turret1", + "mesh_index": -1, + "children": [ + 548 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.5, + "z": 0.3 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pturret", + "mesh_index": 156, + "children": [ + 549 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pgun", + "mesh_index": 157, + "children": [ + 550 + ], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.12, + "z": 0.0 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + }, + { + "Object3d": { + "name": "pfirepoint", + "mesh_index": -1, + "children": [], + "transformation": { + "translation": { + "x": 0.0, + "y": 0.0, + "z": -1.1 + }, + "rotation": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + } + } + } +] diff --git a/CSVM.Tests/fixtures/synthetic/probe_effects/cam_anim.json b/CSVM.Tests/fixtures/synthetic/probe_effects/cam_anim.json new file mode 100644 index 000000000..19486cefe --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/probe_effects/cam_anim.json @@ -0,0 +1,226 @@ +{ + "defs": [ + {"file": "he_ring-he_ground_effect", "def": { + "name": "he_ring", "anim_name": "he_ground_effect", "anim_root_name": "he_ring", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "he_ring", "state": true}}}, + {"data": {"CallAnimation": {"name": "call_he_ring1", "parameters": {"AtNode": {"node": "he_ring", "position": {"x": 0.0, "y": 9.0, "z": 0.0}}}}}}, + {"data": {"CallAnimation": {"name": "call_he_trails", "parameters": {"AtNode": {"node": "he_ring"}}}}}, + {"data": {"ObjectMotionFromTo": {"node": "he_ringer", "scale": {"from": {"x": 0.3, "y": 1.0, "z": 0.3}, "to": {"x": 1.6, "y": 1.0, "z": 1.6}}, "run_time": 1.0}}} + ]}] + }}, + {"file": "he_ring1-call_he_ring1", "def": { + "name": "he_ring1", "anim_name": "call_he_ring1", "anim_root_name": "he_ring1", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "he_ringer1", "scale": {"from": {"x": 0.5, "y": 1.0, "z": 0.5}, "to": {"x": 1.8, "y": 1.0, "z": 1.8}}, "run_time": 1.2}}} + ]}] + }}, + {"file": "he_trails-call_he_trails", "def": { + "name": "he_trails", "anim_name": "call_he_trails", "anim_root_name": "he_trails", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "fly_trail1", "translation_range": {"xz": {"min": 0.0, "max": 359.0}, "y": {"min": 35.0, "max": 65.0}, "initial": {"min": 9.0, "max": 13.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 3.5}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "fly_trail2", "translation_range": {"xz": {"min": 0.0, "max": 359.0}, "y": {"min": 35.0, "max": 65.0}, "initial": {"min": 9.0, "max": 13.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 3.5}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "fly_trail3", "translation_range": {"xz": {"min": 0.0, "max": 359.0}, "y": {"min": 35.0, "max": 65.0}, "initial": {"min": 9.0, "max": 13.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 3.5}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "fly_trail4", "translation_range": {"xz": {"min": 0.0, "max": 359.0}, "y": {"min": 35.0, "max": 65.0}, "initial": {"min": 9.0, "max": 13.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 3.5}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "fly_trail5", "translation_range": {"xz": {"min": 0.0, "max": 359.0}, "y": {"min": 50.0, "max": 80.0}, "initial": {"min": 7.0, "max": 10.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 3.5}}} + ]}] + }}, + {"file": "dum_gunhit-3040ap_gunhit", "def": { + "name": "dum_gunhit", "anim_name": "3040ap_gunhit", "anim_root_name": "dum_gunhit", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "dum_gunhit", "state": true}}}, + {"data": {"ObjectMotionFromTo": {"node": "gunhit_chunk", "scale": {"from": {"x": 1.0, "y": 1.0, "z": 1.0}, "to": {"x": 1.6, "y": 1.6, "z": 1.6}}, "run_time": 0.1}}} + ]}] + }}, + {"file": "ballflare.flt-ballflare.flt", "def": { + "name": "ballflare.flt", "anim_name": "ballflare.flt", "anim_root_name": "ballflare.flt", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "flare_disc", "scale": {"from": {"x": 0.5, "y": 0.5, "z": 0.5}, "to": {"x": 6.0, "y": 6.0, "z": 6.0}}, "run_time": 1.5}}} + ]}] + }}, + {"file": "probe_torpbody-probe_torpedo_trail", "def": { + "name": "probe_torpbody", "anim_name": "probe_torpedo_trail", "anim_root_name": "probe_torpbody", "activation": "OnCall", + "reset_state": {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "atbody", "state": true}}}, + {"data": {"ObjectActiveState": {"node": "rightwing", "state": false}}}, + {"data": {"ObjectActiveState": {"node": "leftwing", "state": false}}}, + {"data": {"ObjectActiveState": {"node": "atprop", "state": false}}} + ]}, + "sequences": [{"name": "probe_unfold", "seq_state": "Initial", "events": [ + {"start": {"offset": "Animation", "time": 3.5}, "data": {"ObjectActiveState": {"node": "rightwing", "state": true}}}, + {"start": {"offset": "Animation", "time": 3.5}, "data": {"ObjectActiveState": {"node": "leftwing", "state": true}}}, + {"start": {"offset": "Animation", "time": 4.0}, "data": {"ObjectActiveState": {"node": "atprop", "state": true}}} + ]}] + }}, + {"file": "player-spinprops", "def": { + "name": "player", "anim_name": "spinprops", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "prop1", "state": true}}}, + {"data": {"ObjectActiveState": {"node": "staticprop1", "state": false}}}, + {"data": {"ObjectMotion": {"node": "prop1", "xyz_rotation": {"initial": {"x": 0.0, "y": 0.0, "z": 30.0}, "delta": {"x": 0.0, "y": 0.0, "z": 0.0}}}}} + ]}] + }}, + {"file": "player-startprops", "def": { + "name": "player", "anim_name": "startprops", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"Sound": {"name": "snd_propstart", "at_node": "prop1"}}}, + {"data": {"ObjectActiveState": {"node": "staticprop1", "state": true}}}, + {"start": {"offset": "Animation", "time": 1.5}, "data": {"ObjectActiveState": {"node": "prop1", "state": true}}}, + {"start": {"offset": "Animation", "time": 1.5}, "data": {"ObjectActiveState": {"node": "staticprop1", "state": false}}} + ]}] + }}, + {"file": "player-agyro_rotors", "def": { + "name": "player", "anim_name": "agyro_rotors", "activation": "OnCall", + "reset_state": {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "prop1", "state": true}}}, + {"data": {"ObjectActiveState": {"node": "rotor1", "state": true}}}, + {"data": {"ObjectActiveState": {"node": "staticprop1", "state": false}}} + ]}, + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotion": {"node": "prop1", "xyz_rotation": {"initial": {"x": 0.0, "y": 0.0, "z": 24.0}, "delta": {"x": 0.0, "y": 0.0, "z": 0.0}}}}}, + {"data": {"ObjectMotion": {"node": "rotor1", "xyz_rotation": {"initial": {"x": 0.0, "y": 18.0, "z": 0.0}, "delta": {"x": 0.0, "y": 0.0, "z": 0.0}}}}} + ]}] + }}, + {"file": "player-stopprops", "def": { + "name": "player", "anim_name": "stopprops", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "staticprop1", "state": true}}}, + {"data": {"ObjectActiveState": {"node": "prop1", "state": false}}} + ]}] + }}, + {"file": "player-pdpanel4", "def": { + "name": "player", "anim_name": "pdpanel4", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp4"}}}}} + ]}] + }}, + {"file": "player-pdpanel5", "def": { + "name": "player", "anim_name": "pdpanel5", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp5"}}}}} + ]}] + }}, + {"file": "player-pdpanel7", "def": { + "name": "player", "anim_name": "pdpanel7", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp7", "position": {"x": 2.0, "y": 0.0, "z": 0.0}}}}}}, + {"start": {"offset": "Animation", "time": 0.2}, "data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp7", "position": {"x": -2.0, "y": 0.0, "z": 0.0}}}}}}, + {"start": {"offset": "Animation", "time": 0.5}, "data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp7", "position": {"x": -2.0, "y": 0.0, "z": 0.0}}}}}}, + {"start": {"offset": "Animation", "time": 0.9}, "data": {"CallAnimation": {"name": "gimmeflakes", "parameters": {"AtNode": {"node": "pdp7", "position": {"x": -2.0, "y": 0.0, "z": 0.0}}}}}} + ]}] + }}, + {"file": "planeflakes-gimmeflakes", "def": { + "name": "planeflakes", "anim_name": "gimmeflakes", "anim_root_name": "planeflakes", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "flake1", "translation_range": {"xz": {"min": 0.0, "max": 120.0}, "y": {"min": 20.0, "max": 50.0}, "initial": {"min": 4.0, "max": 7.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 1.0}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "flake2", "translation_range": {"xz": {"min": 120.0, "max": 240.0}, "y": {"min": 20.0, "max": 50.0}, "initial": {"min": 4.0, "max": 7.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 1.0}}}, + {"start": {"offset": "Sequence", "time": 0.0}, "data": {"ObjectMotion": {"node": "flake3", "translation_range": {"xz": {"min": 240.0, "max": 359.0}, "y": {"min": 20.0, "max": 50.0}, "initial": {"min": 4.0, "max": 7.0}, "delta": {"min": 0.0, "max": 0.0}}, "gravity": {"value": -9.8}, "run_time": 1.0}}} + ]}] + }}, + {"file": "sonic_effect-sonic_ground_effect", "def": { + "name": "sonic_effect", "anim_name": "sonic_ground_effect", "anim_root_name": "sonic_effect", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"CallAnimation": {"name": "ring_up1", "parameters": {"AtNode": {"node": "sonic_effect"}}}}}, + {"data": {"CallAnimation": {"name": "ring_up2", "parameters": {"AtNode": {"node": "sonic_effect", "position": {"x": 0.0, "y": 3.0, "z": 0.0}}}}}}, + {"data": {"ObjectMotionFromTo": {"node": "sonic_emit1", "translate": {"from": {"x": 0.0, "y": 0.5, "z": 0.0}, "to": {"x": 0.0, "y": 8.0, "z": 0.0}}, "run_time": 1.5}}} + ]}] + }}, + {"file": "sonic_ring1-ring_up1", "def": { + "name": "sonic_ring1", "anim_name": "ring_up1", "anim_root_name": "sonic_ring1", "activation": "OnCall", + "reset_state": {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "sonic_ring", "state": true}}}, + {"data": {"ObjectOpacityState": {"node": "sonic_ring", "state": false}}}, + {"data": {"ObjectScaleState": {"node": "sonic_ring", "state": {"x": 1.0, "y": 1.0, "z": 1.0}}}} + ]}, + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "sonic_ring", "scale": {"from": {"x": 0.4, "y": 1.0, "z": 0.4}, "to": {"x": 3.0, "y": 1.0, "z": 3.0}}, "run_time": 1.2}}}, + {"data": {"ObjectOpacityState": {"node": "sonic_ring", "state": true, "opacity": 0.0}}}, + {"data": {"ObjectActiveState": {"node": "sonic_ring", "state": false}}} + ]}] + }}, + {"file": "sonic_ring2-ring_up2", "def": { + "name": "sonic_ring2", "anim_name": "ring_up2", "anim_root_name": "sonic_ring2", "activation": "OnCall", + "reset_state": {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "sonic_ring", "state": true}}}, + {"data": {"ObjectOpacityState": {"node": "sonic_ring", "state": false}}}, + {"data": {"ObjectScaleState": {"node": "sonic_ring", "state": {"x": 1.0, "y": 1.0, "z": 1.0}}}} + ]}, + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "sonic_ring", "scale": {"from": {"x": 0.6, "y": 1.0, "z": 0.6}, "to": {"x": 2.4, "y": 1.0, "z": 2.4}}, "run_time": 0.9}}}, + {"data": {"ObjectOpacityState": {"node": "sonic_ring", "state": true, "opacity": 0.0}}}, + {"data": {"ObjectActiveState": {"node": "sonic_ring", "state": false}}} + ]}] + }}, + {"file": "player-player_crash_water", "def": { + "name": "player", "anim_name": "player_crash_water", "anim_root_name": "player", "activation": "OnCall", + "sequences": [{"name": "probe_crash_water", "seq_state": "Initial", "events": [ + {"data": {"CallAnimation": {"name": "call_crash_trails"}}}, + {"data": {"CallAnimation": {"name": "large_10sec_fire"}}}, + {"data": {"CallAnimation": {"name": "plane_big_splash", "wait_for_completion": true}}}, + {"data": {"CallAnimation": {"name": "large_steam_spray"}}} + ]}] + }}, + {"file": "carnage_trails-call_crash_trails", "def": { + "name": "carnage_trails", "anim_name": "call_crash_trails", "anim_root_name": "carnage_trails", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "piece1", "translate": {"from": {"x": 0.0, "y": 0.0, "z": 0.0}, "to": {"x": 0.0, "y": 2.0, "z": 0.0}}, "run_time": 1.0}}} + ]}] + }}, + {"file": "large_fire-large_10sec_fire", "def": { + "name": "large_fire", "anim_name": "large_10sec_fire", "anim_root_name": "large_fire", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "piece2", "scale": {"from": {"x": 1.0, "y": 1.0, "z": 1.0}, "to": {"x": 2.0, "y": 2.0, "z": 2.0}}, "run_time": 2.0}}} + ]}] + }}, + {"file": "huge_splash_model-plane_big_splash", "def": { + "name": "huge_splash_model", "anim_name": "plane_big_splash", "anim_root_name": "huge_splash_model", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"PufferState": {"name": "splasher", "active_state": 1, "at_node": "sp_1", "interval_garbage": {"interval_value": 0.05}, "number": 4, "lifetime_range": {"min": 0.4, "max": 0.9}, "size_range": {"min": 1.0, "max": 3.0}, "textures": [{"name": "smoke101"}]}}}, + {"data": {"ObjectActiveState": {"node": "sp_1", "state": false}}}, + {"data": {"ObjectMotionFromTo": {"node": "splash_polys", "scale": {"from": {"x": 0.2, "y": 0.2, "z": 0.2}, "to": {"x": 1.0, "y": 1.0, "z": 1.0}}, "run_time": 0.5}}}, + {"data": {"PufferState": {"name": "splasher", "active_state": 0, "at_node": "sp_1"}}}, + {"data": {"ObjectMotionFromTo": {"node": "ripple1", "scale": {"from": {"x": 1.0, "y": 1.0, "z": 1.0}, "to": {"x": 1.4, "y": 1.4, "z": 1.4}}, "run_time": 2.5}}} + ]}] + }}, + {"file": "white_water_impact-large_steam_spray", "def": { + "name": "white_water_impact", "anim_name": "large_steam_spray", "anim_root_name": "white_water_impact", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectMotionFromTo": {"node": "piece3", "scale": {"from": {"x": 0.5, "y": 0.5, "z": 0.5}, "to": {"x": 1.5, "y": 1.5, "z": 1.5}}, "run_time": 1.0}}} + ]}] + }}, + {"file": "m_bld_healthy-m_build01", "def": { + "name": "m_bld_healthy", "anim_name": "m_build01", "activation": "OnCall", + "sequences": [ + {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "m_bld_healthy", "state": false}}}, + {"data": {"ObjectActiveState": {"node": "m_bld_destroyed", "state": true}}}, + {"data": {"PufferState": {"name": "trailpuffer3", "active_state": 1, "at_node": "part3", "interval_garbage": {"interval_value": 0.1}, "number": 1, "lifetime_range": {"min": 1.0, "max": 1.5}, "size_range": {"min": 1.0, "max": 2.0}, "textures": [{"name": "smoke102"}]}}}, + {"data": {"ObjectMotion": {"node": "part3", "translation": {"initial": {"x": 0.0, "y": 12.0, "z": 5.0}, "delta": {"x": 0.0, "y": 0.0, "z": 0.0}, "rnd_xz": {"x": 0.0, "y": 0.0, "z": 1.0}}, "gravity": {"value": -9.8}, "run_time": 2.4}}} + ]}, + {"name": "sparkout3", "seq_state": "Initial", "events": [ + {"start": {"offset": "Sequence", "time": 1.8}, "data": {"ObjectActiveState": {"node": "part3", "state": false}}} + ]} + ] + }}, + {"file": "player-bullet1", "def": { + "name": "player", "anim_name": "bullet1", "activation": "OnCall", + "sequences": [ + {"name": "", "seq_state": "Initial", "events": [ + {"data": {"If": {"condition": {"PlayerFirstPerson": 0}}}}, + {"data": "Else"}, + {"data": {"CallAnimation": {"name": "two_bulletholes_a"}}}, + {"data": "Endif"} + ]}, + {"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "probe_bul1", "state": true}}} + ]} + ] + }}, + {"file": "player-two_bulletholes_a", "def": { + "name": "player", "anim_name": "two_bulletholes_a", "activation": "OnCall", + "sequences": [{"name": "", "seq_state": "Initial", "events": [ + {"data": {"ObjectActiveState": {"node": "probe_hole1", "state": true}}} + ]}] + }} + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/probe_effects/templates.json b/CSVM.Tests/fixtures/synthetic/probe_effects/templates.json new file mode 100644 index 000000000..b10d1f112 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/probe_effects/templates.json @@ -0,0 +1,67 @@ +{ + "materials": [ + {"Colored": {"color": {"r": 230.0, "g": 150.0, "b": 60.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 90.0, "g": 90.0, "b": 96.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 200.0, "g": 210.0, "b": 230.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 120.0, "g": 96.0, "b": 70.0}, "alpha": 255.0, "soil": "Default"}}, + {"Colored": {"color": {"r": 40.0, "g": 36.0, "b": 32.0}, "alpha": 255.0, "soil": "Default"}} + ], + "nodes": [ + {"name": "he_ring", "children": [ + {"name": "he_ringer", "box": [[-3.5, 0.0, -3.5], [3.5, 0.25, 3.5]], "material": 0} + ]}, + {"name": "he_ring1", "children": [ + {"name": "he_ringer1", "box": [[-2.5, -2.5, -0.2], [2.5, 2.5, 0.2]], "material": 0} + ]}, + {"name": "he_trails", "children": [ + {"name": "fly_trail1", "at": [1.0, 0.5, 0.0], "box": [[-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]], "material": 1}, + {"name": "fly_trail2", "at": [-1.0, 0.5, 0.0], "box": [[-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]], "material": 1}, + {"name": "fly_trail3", "at": [0.0, 0.5, 1.0], "box": [[-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]], "material": 1}, + {"name": "fly_trail4", "at": [0.0, 0.5, -1.0], "box": [[-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]], "material": 1}, + {"name": "fly_trail5", "at": [0.0, 1.2, 0.0], "box": [[-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]], "material": 1} + ]}, + {"name": "ballflare.flt", "children": [ + {"name": "flare_disc", "box": [[-0.5, -0.5, -0.05], [0.5, 0.5, 0.05]], "material": 2} + ]}, + {"name": "dum_gunhit", "children": [ + {"name": "gunhit_chunk", "box": [[-0.4, 0.0, -0.4], [0.4, 0.6, 0.4]], "material": 1} + ]}, + {"name": "planeflakes", "children": [ + {"name": "flake1", "at": [0.3, 0.0, 0.0], "box": [[-0.15, -0.02, -0.1], [0.15, 0.02, 0.1]], "material": 1}, + {"name": "flake2", "at": [-0.3, 0.1, 0.0], "box": [[-0.12, -0.02, -0.12], [0.12, 0.02, 0.12]], "material": 1}, + {"name": "flake3", "at": [0.0, -0.1, 0.2], "box": [[-0.1, -0.02, -0.15], [0.1, 0.02, 0.15]], "material": 1} + ]}, + {"name": "sonic_effect", "children": [ + {"name": "sonic_emit1", "at": [0.0, 0.5, 0.0]} + ]}, + {"name": "sonic_ring1", "children": [ + {"name": "sonic_ring", "box": [[-2.0, 0.0, -2.0], [2.0, 0.15, 2.0]], "material": 2} + ]}, + {"name": "sonic_ring2", "children": [ + {"name": "sonic_ring", "box": [[-1.5, 0.0, -1.5], [1.5, 0.15, 1.5]], "material": 2} + ]}, + {"name": "probe_torpbody", "children": [ + {"name": "atbody", "box": [[-0.3, -0.3, -1.6], [0.3, 0.3, 1.6]], "material": 1}, + {"name": "rightwing", "at": [0.6, 0.0, 0.4], "box": [[-0.3, -0.03, -0.2], [0.3, 0.03, 0.2]], "material": 1}, + {"name": "leftwing", "at": [-0.6, 0.0, 0.4], "box": [[-0.3, -0.03, -0.2], [0.3, 0.03, 0.2]], "material": 1}, + {"name": "atprop", "at": [0.0, 0.0, 1.7], "box": [[-0.35, -0.35, -0.02], [0.35, 0.35, 0.02]], "material": 4} + ]}, + {"name": "huge_splash_model", "children": [ + {"name": "splash_polys", "box": [[-3.0, 0.0, -3.0], [3.0, 2.0, 3.0]], "material": 2}, + {"name": "sp_1", "at": [0.0, 0.5, 0.0]}, + {"name": "ripple1", "box": [[-4.0, 0.0, -4.0], [4.0, 0.05, 4.0]], "material": 2} + ]}, + {"name": "m_bld_healthy", "box": [[-5.0, 0.0, -5.0], [5.0, 6.0, 5.0]], "material": 3}, + {"name": "carnage_trails"}, + {"name": "large_fire"}, + {"name": "white_water_impact"}, + {"name": "probe_shed", "at": [40.0, 0.0, -30.0], "children": [ + {"name": "healthy", "box": [[-4.0, 0.0, -3.0], [4.0, 5.0, 3.0]], "material": 3}, + {"name": "destroyed", "box": [[-4.5, 0.0, -3.5], [4.5, 1.2, 3.5]], "material": 4} + ]}, + {"name": "probe_tank", "at": [-40.0, 0.0, 30.0], "children": [ + {"name": "healthy", "box": [[-2.5, 0.0, -2.5], [2.5, 7.0, 2.5]], "material": 3}, + {"name": "destroyed", "box": [[-3.0, 0.0, -3.0], [3.0, 1.0, 3.0]], "material": 4} + ]} + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/rof/art.json b/CSVM.Tests/fixtures/synthetic/rof/art.json new file mode 100644 index 000000000..0584dcbfb --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/rof/art.json @@ -0,0 +1,689 @@ +{ + "art": [ + { + "name": "PM_Logo.png", + "width": 600, + "height": 120 + }, + { + "name": "PM_Frame.png", + "width": 300, + "height": 330 + }, + { + "name": "PM_B_Campaign.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_B_InstantAction.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_B_MultiPlayer.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_B_Preferences.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_B_Credits.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_B_Quit.png", + "width": 240, + "height": 160 + }, + { + "name": "PM_AboutBox.png", + "width": 400, + "height": 300 + }, + { + "name": "PM_B_About.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_Box.png", + "width": 400, + "height": 300 + }, + { + "name": "PM_Icons.png", + "width": 48, + "height": 144 + }, + { + "name": "PM_B_Small.png", + "width": 80, + "height": 96 + }, + { + "name": "PP_Back.png", + "width": 600, + "height": 340 + }, + { + "name": "PP_GoBack.png", + "width": 600, + "height": 330 + }, + { + "name": "PP_ApBack.png", + "width": 600, + "height": 350 + }, + { + "name": "PP_VpBack.png", + "width": 640, + "height": 380 + }, + { + "name": "PP_CpBack.png", + "width": 600, + "height": 330 + }, + { + "name": "PP_KbBack.png", + "width": 780, + "height": 390 + }, + { + "name": "PP_B_GameOptions.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_Audio.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_Video.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_Controls.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_ReturnMainMenu.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_ReturnToGame.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_Keys.png", + "width": 220, + "height": 160 + }, + { + "name": "PP_B_Accept.png", + "width": 160, + "height": 160 + }, + { + "name": "PP_B_Cancel.png", + "width": 160, + "height": 160 + }, + { + "name": "PP_B_Reset.png", + "width": 120, + "height": 120 + }, + { + "name": "PP_B_Large.png", + "width": 140, + "height": 112 + }, + { + "name": "PP_B_Check8.png", + "width": 16, + "height": 128 + }, + { + "name": "PP_B_DropUp.png", + "width": 16, + "height": 64 + }, + { + "name": "PP_B_DropDown.png", + "width": 16, + "height": 64 + }, + { + "name": "PP_B_ScrollBar.png", + "width": 16, + "height": 12 + }, + { + "name": "PP_B_ScrollUp.png", + "width": 16, + "height": 48 + }, + { + "name": "PP_B_ScrollDown.png", + "width": 16, + "height": 48 + }, + { + "name": "PP_B_KbBar.png", + "width": 16, + "height": 12 + }, + { + "name": "PP_B_KbUp.png", + "width": 16, + "height": 48 + }, + { + "name": "PP_B_KbDown.png", + "width": 16, + "height": 48 + }, + { + "name": "PP_B_SliderSlot.png", + "width": 160, + "height": 4 + }, + { + "name": "PP_B_Slider.png", + "width": 36, + "height": 20 + }, + { + "name": "PC_Back.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PC_B_Done.png", + "width": 120, + "height": 160 + }, + { + "name": "PM_Panel.png", + "width": 440, + "height": 340 + }, + { + "name": "PM_B_Continue.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_Delete.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_Cancel.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_Up.png", + "width": 16, + "height": 48 + }, + { + "name": "PM_B_Down.png", + "width": 16, + "height": 48 + }, + { + "name": "PM_B_Slider.png", + "width": 16, + "height": 12 + }, + { + "name": "PM_B_DropUp.png", + "width": 16, + "height": 64 + }, + { + "name": "PM_B_DropDown.png", + "width": 16, + "height": 64 + }, + { + "name": "PM_B_Paper.png", + "width": 160, + "height": 112 + }, + { + "name": "PM_B_ReturnBrief.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_B_FlyMission.png", + "width": 200, + "height": 160 + }, + { + "name": "PM_B_Current.png", + "width": 200, + "height": 112 + }, + { + "name": "PM_B_ReturnCabin.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_B_AcceptLoadout.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_B_CancelLoadout.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_Cabin.png", + "width": 800, + "height": 600 + }, + { + "name": "PM_Frame2.png", + "width": 300, + "height": 200 + }, + { + "name": "PM_B_NextMission.png", + "width": 200, + "height": 160 + }, + { + "name": "PM_B_Previous.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_PlaneX.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_ReturnMM.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_Book.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PM_B_ViewAll.png", + "width": 160, + "height": 160 + }, + { + "name": "PM_B_BackTab.png", + "width": 90, + "height": 160 + }, + { + "name": "PM_B_MoreTab.png", + "width": 90, + "height": 160 + }, + { + "name": "PM_Plane.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PM_B_AcceptSel.png", + "width": 180, + "height": 160 + }, + { + "name": "PM_Ammo.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PM_PlaneTop.png", + "width": 120, + "height": 880 + }, + { + "name": "PM_PlaneFront.png", + "width": 120, + "height": 880 + }, + { + "name": "PM_B_CancelSel.png", + "width": 180, + "height": 160 + }, + { + "name": "PI_IA_Back.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PI_B_Exit.png", + "width": 200, + "height": 128 + }, + { + "name": "PI_B_Build.png", + "width": 200, + "height": 128 + }, + { + "name": "PI_B_Paper.png", + "width": 132, + "height": 112 + }, + { + "name": "PI_B_Radio.png", + "width": 18, + "height": 144 + }, + { + "name": "PI_B_Up.png", + "width": 16, + "height": 64 + }, + { + "name": "PI_B_Down.png", + "width": 16, + "height": 64 + }, + { + "name": "PI_B_ScrollBar.png", + "width": 16, + "height": 12 + }, + { + "name": "PI_B_ScrollUp.png", + "width": 16, + "height": 48 + }, + { + "name": "PI_B_ScrollDown.png", + "width": 16, + "height": 48 + }, + { + "name": "PI_IAWU_Back.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PI_IAWU_Line.png", + "width": 240, + "height": 4 + }, + { + "name": "PH_Back.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PH_NamePanel.png", + "width": 250, + "height": 170 + }, + { + "name": "PH_B_OkCancel.png", + "width": 80, + "height": 96 + }, + { + "name": "PH_B_Check8.png", + "width": 16, + "height": 128 + }, + { + "name": "PH_Bar.png", + "width": 24, + "height": 24 + }, + { + "name": "PH_B_Cancel.png", + "width": 160, + "height": 160 + }, + { + "name": "PH_B_Ready.png", + "width": 160, + "height": 160 + }, + { + "name": "PH_B_Sell.png", + "width": 160, + "height": 160 + }, + { + "name": "PH_Tab.png", + "width": 120, + "height": 120 + }, + { + "name": "PH_B_ScrollBar.png", + "width": 16, + "height": 12 + }, + { + "name": "PH_B_ScrollUp.png", + "width": 16, + "height": 48 + }, + { + "name": "PH_B_ScrollDown.png", + "width": 16, + "height": 48 + }, + { + "name": "PH_B_DropUp.png", + "width": 16, + "height": 64 + }, + { + "name": "PH_B_DropDown.png", + "width": 16, + "height": 64 + }, + { + "name": "PH_Rule.png", + "width": 320, + "height": 4 + }, + { + "name": "PH_Decals.tga", + "width": 64, + "height": 3200 + }, + { + "name": "PH_B_Paper.png", + "width": 160, + "height": 112 + }, + { + "name": "PH_Inventory.jpg", + "width": 800, + "height": 600 + }, + { + "name": "PH_PlaneIcons.png", + "width": 100, + "height": 1152 + }, + { + "name": "PH_B_Done.png", + "width": 160, + "height": 160 + }, + { + "name": "activepointerz.png", + "width": 32, + "height": 32 + }, + { + "name": "passivepointerz.png", + "width": 32, + "height": 32 + }, + { + "name": "arial8.tga", + "width": 128, + "height": 128 + }, + { + "name": "PX_B_ReadyToExport.png", + "width": 160, + "height": 160 + }, + { + "name": "PX_B_CancelExport.png", + "width": 160, + "height": 160 + }, + { + "name": "MP_OPTIONSBACKGROUND.JPG", + "width": 800, + "height": 600 + }, + { + "name": "MP_GAMESBACKGROUND.JPG", + "width": 800, + "height": 600 + }, + { + "name": "MP_ERRORMESSAGEBACKGROUND.JPG", + "width": 400, + "height": 240 + }, + { + "name": "MP_GAMESALPHA.PNG", + "width": 360, + "height": 20 + }, + { + "name": "MP_B_RADIO.PNG", + "width": 18, + "height": 144 + }, + { + "name": "MP_B_SMALL.PNG", + "width": 90, + "height": 96 + }, + { + "name": "MP_B_MEDIUM.PNG", + "width": 140, + "height": 96 + }, + { + "name": "MP_B_LARGE.PNG", + "width": 200, + "height": 96 + }, + { + "name": "MP_B_EXITMULTIPLAYER.PNG", + "width": 200, + "height": 128 + }, + { + "name": "MP_B_CHECKBOXLARGE.PNG", + "width": 24, + "height": 192 + }, + { + "name": "MP_LOBBY_BACKGROUND.JPG", + "width": 800, + "height": 600 + }, + { + "name": "MP_LOBBY_MISSION.PNG", + "width": 360, + "height": 260 + }, + { + "name": "MP_LOBBY_PLANE.PNG", + "width": 360, + "height": 260 + }, + { + "name": "MP_LOBBY_AMMO.PNG", + "width": 360, + "height": 260 + }, + { + "name": "MP_LOBBY_STATSCREEN.PNG", + "width": 360, + "height": 260 + }, + { + "name": "MP_LOBBY_TABLARGE.PNG", + "width": 120, + "height": 96 + }, + { + "name": "MP_LOBBY_TABSMALL.PNG", + "width": 80, + "height": 96 + }, + { + "name": "MP_B_RADIO8STATESSM.PNG", + "width": 14, + "height": 112 + }, + { + "name": "MP_B_CHECKBOX8STATES.PNG", + "width": 16, + "height": 128 + }, + { + "name": "MP_B_CHECKBOX.PNG", + "width": 16, + "height": 16 + }, + { + "name": "MP_B_LISTBOXARROW.PNG", + "width": 16, + "height": 64 + }, + { + "name": "MP_PLANEICONSTOPFRONT.PNG", + "width": 64, + "height": 768 + }, + { + "name": "MP_B_SCROLLUP.PNG", + "width": 16, + "height": 48 + }, + { + "name": "MP_B_SCROLLDOWN.PNG", + "width": 16, + "height": 48 + }, + { + "name": "MP_CREATETEAMBACKGROUND.PNG", + "width": 360, + "height": 240 + } + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/rof/ui_strings.json b/CSVM.Tests/fixtures/synthetic/rof/ui_strings.json new file mode 100644 index 000000000..a538134f8 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/rof/ui_strings.json @@ -0,0 +1,870 @@ +[ + { + "id": 9000, + "symbol": "IDS_AF_TITLE", + "font": null, + "text": "A) PICK A SHELL", + "dll": "langui" + }, + { + "id": 9001, + "symbol": "IDS_AP_EVOLUME_DESC", + "font": null, + "text": "Probe the effects' level.", + "dll": "langui" + }, + { + "id": 9002, + "symbol": "IDS_AP_EVOLUME_TITLE", + "font": null, + "text": "Effect Level", + "dll": "langui" + }, + { + "id": 9003, + "symbol": "IDS_AP_MUSIC_DESC", + "font": null, + "text": "Probe the score in flight.", + "dll": "langui" + }, + { + "id": 9004, + "symbol": "IDS_AP_MUSIC_TITLE", + "font": null, + "text": "Score On", + "dll": "langui" + }, + { + "id": 9005, + "symbol": "IDS_AP_MVOLUME_DESC", + "font": null, + "text": "Probe the score's level.", + "dll": "langui" + }, + { + "id": 9006, + "symbol": "IDS_AP_MVOLUME_TITLE", + "font": null, + "text": "Score Level", + "dll": "langui" + }, + { + "id": 9007, + "symbol": "IDS_AP_TITLE", + "font": null, + "text": "LOUDNESS", + "dll": "langui" + }, + { + "id": 9008, + "symbol": "IDS_AP_VVOLUME_DESC", + "font": null, + "text": "Probe the speech level.", + "dll": "langui" + }, + { + "id": 9009, + "symbol": "IDS_AP_VVOLUME_TITLE", + "font": null, + "text": "Speech Level", + "dll": "langui" + }, + { + "id": 9010, + "symbol": "IDS_AR_LEFT_TITLE", + "font": null, + "text": "Port", + "dll": "langui" + }, + { + "id": 9011, + "symbol": "IDS_AR_NOSE_TITLE", + "font": null, + "text": "Front", + "dll": "langui" + }, + { + "id": 9012, + "symbol": "IDS_AR_RIGHT_TITLE", + "font": null, + "text": "Starboard", + "dll": "langui" + }, + { + "id": 9013, + "symbol": "IDS_AR_TAIL_TITLE", + "font": null, + "text": "Rear", + "dll": "langui" + }, + { + "id": 9014, + "symbol": "IDS_AR_TITLE", + "font": null, + "text": "C) PICK PLATING", + "dll": "langui" + }, + { + "id": 9015, + "symbol": "IDS_CANCEL", + "font": null, + "text": "Back Out", + "dll": "langui" + }, + { + "id": 9016, + "symbol": "IDS_CP_JOYSTICK_DESC", + "font": null, + "text": "Probe the device kind.", + "dll": "langui" + }, + { + "id": 9017, + "symbol": "IDS_CP_JOYSTICK_TITLE", + "font": null, + "text": "Device Kind", + "dll": "langui" + }, + { + "id": 9018, + "symbol": "IDS_CP_KEYS_DESC", + "font": null, + "text": "Probe each action's key and button.", + "dll": "langui" + }, + { + "id": 9019, + "symbol": "IDS_CP_MOUSE_DESC", + "font": null, + "text": "Probe the pointer's speed.", + "dll": "langui" + }, + { + "id": 9020, + "symbol": "IDS_CP_MOUSE_TITLE", + "font": null, + "text": "Pointer Speed", + "dll": "langui" + }, + { + "id": 9021, + "symbol": "IDS_CP_TITLE", + "font": null, + "text": "HANDS ON", + "dll": "langui" + }, + { + "id": 9022, + "symbol": "IDS_EN_TITLE", + "font": null, + "text": "B) PICK A MOTOR", + "dll": "langui" + }, + { + "id": 9023, + "symbol": "IDS_GN_TITLE", + "font": null, + "text": "D) PICK CANNONS", + "dll": "langui" + }, + { + "id": 9024, + "symbol": "IDS_GO_DIFF_DESC", + "font": null, + "text": "Probe challenge for a story run.", + "dll": "langui" + }, + { + "id": 9025, + "symbol": "IDS_GO_DIFF_TITLE", + "font": null, + "text": "Challenge", + "dll": "langui" + }, + { + "id": 9026, + "symbol": "IDS_GO_HEAD_DESC", + "font": null, + "text": "Probe the head following a turn.", + "dll": "langui" + }, + { + "id": 9027, + "symbol": "IDS_GO_HEAD_TITLE", + "font": null, + "text": "Head Follows Turn", + "dll": "langui" + }, + { + "id": 9028, + "symbol": "IDS_GO_TITLE", + "font": null, + "text": "PLAY RULES", + "dll": "langui" + }, + { + "id": 9029, + "symbol": "IDS_GO_VIEW_DESC", + "font": null, + "text": "Probe the view a flight opens on.", + "dll": "langui" + }, + { + "id": 9030, + "symbol": "IDS_GO_VIEW_TITLE", + "font": null, + "text": "Opening View", + "dll": "langui" + }, + { + "id": 9031, + "symbol": "IDS_HA_SELLEXPORT", + "font": null, + "text": "Probe a craft to trade or send.", + "dll": "langui" + }, + { + "id": 9032, + "symbol": "IDS_HA_TITLE", + "font": null, + "text": "STOCKROOM", + "dll": "langui" + }, + { + "id": 9033, + "symbol": "IDS_HP_POINT1_TITLE", + "font": null, + "text": "Pylon A", + "dll": "langui" + }, + { + "id": 9034, + "symbol": "IDS_HP_POINT2_TITLE", + "font": null, + "text": "Pylon B", + "dll": "langui" + }, + { + "id": 9035, + "symbol": "IDS_HP_TITLE", + "font": null, + "text": "E) PICK PYLONS", + "dll": "langui" + }, + { + "id": 9036, + "symbol": "IDS_IAWU_DESTROYED_TITLE", + "font": null, + "text": "Downed", + "dll": "langui" + }, + { + "id": 9037, + "symbol": "IDS_IAWU_SHOTS_TITLE", + "font": null, + "text": "Snaps", + "dll": "langui" + }, + { + "id": 9038, + "symbol": "IDS_IAWU_TIME_TITLE", + "font": null, + "text": "Clock", + "dll": "langui" + }, + { + "id": 9039, + "symbol": "IDS_IAWU_TITLE", + "font": null, + "text": "SORTIE TALLY", + "dll": "langui" + }, + { + "id": 9040, + "symbol": "IDS_IAWU_ZONES_TITLE", + "font": null, + "text": "Gates", + "dll": "langui" + }, + { + "id": 9041, + "symbol": "IDS_IA_TABLE_TITLE", + "font": null, + "text": "Tally", + "dll": "langui" + }, + { + "id": 9042, + "symbol": "IDS_KB_COMMAND_TITLE", + "font": null, + "text": "Deed", + "dll": "langui" + }, + { + "id": 9043, + "symbol": "IDS_KB_CONTA_TITLE", + "font": null, + "text": "First Input", + "dll": "langui" + }, + { + "id": 9044, + "symbol": "IDS_KB_CONTB_TITLE", + "font": null, + "text": "Second Input", + "dll": "langui" + }, + { + "id": 9045, + "symbol": "IDS_KB_DEFAULT_DESC", + "font": null, + "text": "Probe: pick an input, then press its key.", + "dll": "langui" + }, + { + "id": 9046, + "symbol": "IDS_KB_TITLE", + "font": null, + "text": "KEY SHEET", + "dll": "langui" + }, + { + "id": 9047, + "symbol": "IDS_OK", + "font": null, + "text": "Agreed", + "dll": "langui" + }, + { + "id": 9048, + "symbol": "IDS_OL_AMMODESC_TITLE", + "font": null, + "text": "About the Shell", + "dll": "langui" + }, + { + "id": 9049, + "symbol": "IDS_OL_AMMO_CAPTION", + "font": null, + "text": "Probe a shell per cannon.", + "dll": "langui" + }, + { + "id": 9050, + "symbol": "IDS_OL_AMMO_TITLE", + "font": null, + "text": "Shells", + "dll": "langui" + }, + { + "id": 9051, + "symbol": "IDS_OL_ROCKETDESC_TITLE", + "font": null, + "text": "About the Store", + "dll": "langui" + }, + { + "id": 9052, + "symbol": "IDS_OL_ROCKET_CAPTION", + "font": null, + "text": "Probe a store per pylon.", + "dll": "langui" + }, + { + "id": 9053, + "symbol": "IDS_OL_ROCKET_TITLE", + "font": null, + "text": "Pylon Stores", + "dll": "langui" + }, + { + "id": 9054, + "symbol": "IDS_OL_TITLE", + "font": null, + "text": "ARMS RACK", + "dll": "langui" + }, + { + "id": 9055, + "symbol": "IDS_PF_AUDIO_DESC", + "font": null, + "text": "Probe loudness levels.", + "dll": "langui" + }, + { + "id": 9056, + "symbol": "IDS_PF_CONTROLS_DESC", + "font": null, + "text": "Probe stick, keys and pointer.", + "dll": "langui" + }, + { + "id": 9057, + "symbol": "IDS_PF_GO_DESC", + "font": null, + "text": "Probe rules for play and the opening view.", + "dll": "langui" + }, + { + "id": 9058, + "symbol": "IDS_PF_TITLE", + "font": null, + "text": "SETUP DESK", + "dll": "langui" + }, + { + "id": 9059, + "symbol": "IDS_PF_VIDEO_DESC", + "font": null, + "text": "Probe picture settings.", + "dll": "langui" + }, + { + "id": 9060, + "symbol": "IDS_PI_CONTINUED", + "font": null, + "text": "[onward ...]", + "dll": "langui" + }, + { + "id": 9061, + "symbol": "IDS_PI_ENEMY", + "font": null, + "text": "Foes:", + "dll": "langui" + }, + { + "id": 9062, + "symbol": "IDS_PI_ENV", + "font": null, + "text": "Setting:", + "dll": "langui" + }, + { + "id": 9063, + "symbol": "IDS_PI_FLY", + "font": null, + "text": "Launch", + "dll": "langui" + }, + { + "id": 9064, + "symbol": "IDS_PI_GOBACK", + "font": null, + "text": "[return ...]", + "dll": "langui" + }, + { + "id": 9065, + "symbol": "IDS_PI_MISSION", + "font": null, + "text": "Sortie:", + "dll": "langui" + }, + { + "id": 9066, + "symbol": "IDS_PI_PILOT", + "font": null, + "text": "Flyer", + "dll": "langui" + }, + { + "id": 9067, + "symbol": "IDS_PI_PLANE", + "font": null, + "text": "Craft:", + "dll": "langui" + }, + { + "id": 9068, + "symbol": "IDS_PI_STORYINSTR", + "font": null, + "text": "Probe the details below.", + "dll": "langui" + }, + { + "id": 9069, + "symbol": "IDS_PI_TABLE", + "font": null, + "text": "Sorties", + "dll": "langui" + }, + { + "id": 9070, + "symbol": "IDS_PI_TABLEINSTR", + "font": null, + "text": "Probe a sortie below.", + "dll": "langui" + }, + { + "id": 9071, + "symbol": "IDS_PI_VIEW", + "font": null, + "text": "Read", + "dll": "langui" + }, + { + "id": 9072, + "symbol": "IDS_PI_WEAPONS", + "font": null, + "text": "Arms", + "dll": "langui" + }, + { + "id": 9073, + "symbol": "IDS_PI_WINGMEN", + "font": null, + "text": "Escorts", + "dll": "langui" + }, + { + "id": 9074, + "symbol": "IDS_PM_CHANGEPLANE", + "font": null, + "text": "Swap Craft", + "dll": "langui" + }, + { + "id": 9075, + "symbol": "IDS_PN_DEFAULTLOADOUT", + "font": null, + "text": "Stock Fit", + "dll": "langui" + }, + { + "id": 9076, + "symbol": "IDS_PN_TITLE", + "font": null, + "text": "CRAFT LABEL", + "dll": "langui" + }, + { + "id": 9077, + "symbol": "IDS_PS_B_EXPORT", + "font": null, + "text": "Send Out", + "dll": "langui" + }, + { + "id": 9078, + "symbol": "IDS_PS_B_SELL", + "font": null, + "text": "Trade Away", + "dll": "langui" + }, + { + "id": 9079, + "symbol": "IDS_PT_COLOR_TITLE", + "font": null, + "text": "Tint", + "dll": "langui" + }, + { + "id": 9080, + "symbol": "IDS_PT_DECAL_TITLE", + "font": null, + "text": "Badges", + "dll": "langui" + }, + { + "id": 9081, + "symbol": "IDS_PT_PATTERN_TITLE", + "font": null, + "text": "Scheme", + "dll": "langui" + }, + { + "id": 9082, + "symbol": "IDS_PT_SHADE_TITLE", + "font": null, + "text": "Tone", + "dll": "langui" + }, + { + "id": 9083, + "symbol": "IDS_PT_TITLE", + "font": null, + "text": "F) PICK COLOURS", + "dll": "langui" + }, + { + "id": 9084, + "symbol": "IDS_PUR_B_PURCHASE", + "font": null, + "text": "Buy It", + "dll": "langui" + }, + { + "id": 9085, + "symbol": "IDS_PUR_COST_TITLE", + "font": null, + "text": "Price", + "dll": "langui" + }, + { + "id": 9086, + "symbol": "IDS_PUR_ITEM_TITLE", + "font": null, + "text": "Part", + "dll": "langui" + }, + { + "id": 9087, + "symbol": "IDS_PUR_TITLE", + "font": null, + "text": "G) TALLY", + "dll": "langui" + }, + { + "id": 9088, + "symbol": "IDS_PUR_TOTALS_TITLE", + "font": null, + "text": "Sum", + "dll": "langui" + }, + { + "id": 9089, + "symbol": "IDS_PUR_WEIGHT_TITLE", + "font": null, + "text": "Mass", + "dll": "langui" + }, + { + "id": 9090, + "symbol": "IDS_PX_AIRFRAME_TITLE", + "font": null, + "text": "SHELL: %1!s!", + "dll": "langui" + }, + { + "id": 9091, + "symbol": "IDS_PX_B_AIRFRAME", + "font": null, + "text": "Shell", + "dll": "langui" + }, + { + "id": 9092, + "symbol": "IDS_PX_B_ARMOR", + "font": null, + "text": "Plating", + "dll": "langui" + }, + { + "id": 9093, + "symbol": "IDS_PX_B_ENGINE", + "font": null, + "text": "Motor", + "dll": "langui" + }, + { + "id": 9094, + "symbol": "IDS_PX_B_GUNS", + "font": null, + "text": "Cannons", + "dll": "langui" + }, + { + "id": 9095, + "symbol": "IDS_PX_B_HARDPOINTS", + "font": null, + "text": "Pylons", + "dll": "langui" + }, + { + "id": 9096, + "symbol": "IDS_PX_B_PAINT", + "font": null, + "text": "Colours", + "dll": "langui" + }, + { + "id": 9097, + "symbol": "IDS_PX_CASH_TITLE", + "font": null, + "text": "Purse:", + "dll": "langui" + }, + { + "id": 9098, + "symbol": "IDS_PX_CURRENTWEIGHT_TITLE", + "font": null, + "text": "LIFT USED: %1!d!", + "dll": "langui" + }, + { + "id": 9099, + "symbol": "IDS_PX_PLANECOST_TITLE", + "font": null, + "text": "CRAFT PRICE: %1!d!", + "dll": "langui" + }, + { + "id": 9100, + "symbol": "IDS_PX_PLANENAME_TITLE", + "font": null, + "text": "CRAFT LABEL:", + "dll": "langui" + }, + { + "id": 9101, + "symbol": "IDS_PX_WEIGHTCAPACITY_TITLE", + "font": null, + "text": "LIFT LIMIT: %1!d!", + "dll": "langui" + }, + { + "id": 9102, + "symbol": "IDS_VP_CLUTTER_DESC", + "font": null, + "text": "Probe ground litter.", + "dll": "langui" + }, + { + "id": 9103, + "symbol": "IDS_VP_CLUTTER_TITLE", + "font": null, + "text": "Ground Litter", + "dll": "langui" + }, + { + "id": 9104, + "symbol": "IDS_VP_DEVICE_DESC", + "font": null, + "text": "Probe the renderer.", + "dll": "langui" + }, + { + "id": 9105, + "symbol": "IDS_VP_DEVICE_TITLE", + "font": null, + "text": "Renderer", + "dll": "langui" + }, + { + "id": 9106, + "symbol": "IDS_VP_DISPLAY_DESC", + "font": null, + "text": "Probe the screen size.", + "dll": "langui" + }, + { + "id": 9107, + "symbol": "IDS_VP_DISPLAY_TITLE", + "font": null, + "text": "Screen Size", + "dll": "langui" + }, + { + "id": 9108, + "symbol": "IDS_VP_EFFECTS_DESC", + "font": null, + "text": "Probe the detail of fire and smoke.", + "dll": "langui" + }, + { + "id": 9109, + "symbol": "IDS_VP_EFFECTS_TITLE", + "font": null, + "text": "Effect Detail", + "dll": "langui" + }, + { + "id": 9110, + "symbol": "IDS_VP_LIGHT_DESC", + "font": null, + "text": "Probe the detail of lights.", + "dll": "langui" + }, + { + "id": 9111, + "symbol": "IDS_VP_LIGHT_TITLE", + "font": null, + "text": "Light Detail", + "dll": "langui" + }, + { + "id": 9112, + "symbol": "IDS_VP_OBJECTS_DESC", + "font": null, + "text": "Probe the detail of objects.", + "dll": "langui" + }, + { + "id": 9113, + "symbol": "IDS_VP_OBJECTS_TITLE", + "font": null, + "text": "Object Detail", + "dll": "langui" + }, + { + "id": 9114, + "symbol": "IDS_VP_SHADOWS_DESC", + "font": null, + "text": "Probe cast shade.", + "dll": "langui" + }, + { + "id": 9115, + "symbol": "IDS_VP_SHADOWS_TITLE", + "font": null, + "text": "Cast Shade", + "dll": "langui" + }, + { + "id": 9116, + "symbol": "IDS_VP_TEXTURE_DESC", + "font": null, + "text": "Probe the detail of surfaces.", + "dll": "langui" + }, + { + "id": 9117, + "symbol": "IDS_VP_TEXTURE_TITLE", + "font": null, + "text": "Surface Detail", + "dll": "langui" + }, + { + "id": 9118, + "symbol": "IDS_VP_TITLE", + "font": null, + "text": "PICTURE", + "dll": "langui" + }, + { + "id": 9119, + "symbol": "IDS_VP_VIEW_DESC", + "font": null, + "text": "Probe how far the eye reaches.", + "dll": "langui" + }, + { + "id": 9120, + "symbol": "IDS_VP_VIEW_TITLE", + "font": null, + "text": "Sight Distance", + "dll": "langui" + }, + { + "id": 10123, + "symbol": null, + "font": null, + "text": "Probe deathmatch: last flyer up wins.", + "dll": "langui" + }, + { + "id": 10124, + "symbol": null, + "font": null, + "text": "Probe flag run: carry the far banner home.", + "dll": "langui" + }, + { + "id": 10125, + "symbol": null, + "font": null, + "text": "Probe airship duel: guard your own hull.", + "dll": "langui" + } +] diff --git a/CSVM.Tests/fixtures/synthetic/soundsh/manifest.json b/CSVM.Tests/fixtures/synthetic/soundsh/manifest.json new file mode 100644 index 000000000..b2b49622f --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/soundsh/manifest.json @@ -0,0 +1,97 @@ +{ + "wavs": [ + { "name": "probe_engine.wav", "seconds": 1.0, "hz": 110 }, + { "name": "probe_fighter_engine.wav", "seconds": 1.0, "hz": 146 }, + { "name": "probe_brand_engine.wav", "seconds": 1.0, "hz": 128 }, + { "name": "probe_avenger_engine.wav", "seconds": 1.0, "hz": 92 }, + { "name": "probe_hawk_engine.wav", "seconds": 1.0, "hz": 138 }, + { "name": "probe_gyro_engine.wav", "seconds": 1.0, "hz": 172 }, + { "name": "probe_fury_engine.wav", "seconds": 1.0, "hz": 120 }, + { "name": "probe_peace_engine.wav", "seconds": 1.0, "hz": 104 }, + { "name": "probe_warhawk_engine.wav", "seconds": 1.0, "hz": 86 }, + { "name": "probe_kestrel_engine.wav", "seconds": 1.0, "hz": 98 }, + { "name": "probe_engine_cp.wav", "seconds": 1.0, "hz": 82 }, + { "name": "probe_engine_damaged.wav", "seconds": 1.0, "hz": 0 }, + { "name": "probe_rattle.wav", "seconds": 0.5, "hz": 0 }, + { "name": "probe_propstart.wav", "seconds": 1.2, "hz": 64 }, + { "name": "probe_gunloop.wav", "seconds": 0.5, "hz": 330 }, + { "name": "probe_emptyclip.wav", "seconds": 0.1, "hz": 1200 }, + { "name": "probe_fire.wav", "seconds": 0.2, "hz": 0 }, + { "name": "probe_launch.wav", "seconds": 0.6, "hz": 0 }, + { "name": "probe_hit.wav", "seconds": 0.2, "hz": 0 }, + { "name": "probe_boom.wav", "seconds": 1.0, "hz": 0 }, + { "name": "probe_pass1.wav", "seconds": 0.3, "hz": 880 }, + { "name": "probe_pass2.wav", "seconds": 0.3, "hz": 990 }, + { "name": "probe_ricochet1.wav", "seconds": 0.3, "hz": 1500 }, + { "name": "probe_ricochet2.wav", "seconds": 0.3, "hz": 1700 }, + { "name": "probe_windowhit1.wav", "seconds": 0.25, "hz": 0 }, + { "name": "probe_windowhit2.wav", "seconds": 0.25, "hz": 0 }, + { "name": "probe_radio_line.wav", "seconds": 3.5, "hz": 440 }, + { "name": "probe_radio_bark.wav", "seconds": 1.0, "hz": 520 }, + { "name": "music_splash_probe.wav", "seconds": 3.0, "hz": 262 }, + { "name": "music_prebattle_probe1.wav", "seconds": 3.0, "hz": 294 }, + { "name": "music_prebattle_probe2.wav", "seconds": 3.0, "hz": 330 }, + { "name": "music_battle_probe1.wav", "seconds": 3.0, "hz": 196 }, + { "name": "music_battle_probe2.wav", "seconds": 3.0, "hz": 220 }, + { "name": "music_primaryobj_probe1.wav", "seconds": 2.0, "hz": 392 }, + { "name": "music_primaryobj_probe2.wav", "seconds": 2.0, "hz": 440 }, + { "name": "music_secondaryobj_probe1.wav", "seconds": 2.0, "hz": 494 }, + { "name": "music_secondaryobj_probe2.wav", "seconds": 2.0, "hz": 523 }, + { "name": "music_tertiaryobj_probe1.wav", "seconds": 2.0, "hz": 587 }, + { "name": "music_tertiaryobj_probe2.wav", "seconds": 2.0, "hz": 659 }, + { "name": "music_battlesuccess_probe.wav", "seconds": 2.0, "hz": 349 }, + { "name": "music_missionsuccess_probe.wav", "seconds": 2.0, "hz": 698 }, + { "name": "probe_vo2_WA-Turret-A.wav", "seconds": 0.6, "hz": 300 }, + { "name": "probe_vo2_WA-HighDmg-A.wav", "seconds": 0.6, "hz": 310 }, + { "name": "probe_vo2_WA-Attack-A.wav", "seconds": 0.6, "hz": 320 }, + { "name": "probe_vo2_PR-DngrZn-A.wav", "seconds": 0.6, "hz": 330 }, + { "name": "probe_vo2_PR-EnemyDwn-A.wav", "seconds": 0.6, "hz": 340 }, + { "name": "probe_vo2_DI-LowDmg-A.wav", "seconds": 0.6, "hz": 350 }, + { "name": "probe_vo2_DI-MedDmg-A.wav", "seconds": 0.6, "hz": 360 }, + { "name": "probe_vo2_DI-HighDmg-A.wav", "seconds": 0.6, "hz": 370 }, + { "name": "probe_vo2_DA-Bail-A.wav", "seconds": 0.6, "hz": 380 }, + { "name": "probe_vo2_DA-NoBail-A.wav", "seconds": 0.6, "hz": 390 }, + { "name": "probe_vo2_DE-Bail-A.wav", "seconds": 0.6, "hz": 400 }, + { "name": "probe_vo2_DE-NoBail-A.wav", "seconds": 0.6, "hz": 410 }, + { "name": "probe_vo2_GL-AllyDwn-A.wav", "seconds": 0.6, "hz": 420 }, + { "name": "probe_vo2_GL-EnemyDwn-A.wav", "seconds": 0.6, "hz": 430 }, + { "name": "probe_vo2_GL-PlyrDwn-A.wav", "seconds": 0.6, "hz": 440 }, + { "name": "probe_vo2_TA-FailTail-A.wav", "seconds": 0.6, "hz": 450 }, + { "name": "probe_vo2_TA-FailShk-A.wav", "seconds": 0.6, "hz": 460 }, + { "name": "probe_vo2_TA-SucShk-A.wav", "seconds": 0.6, "hz": 470 }, + { "name": "probe_vo2_DS-Ally-A.wav", "seconds": 0.6, "hz": 480 }, + { "name": "probe_vo2_DI-LowDmg-B.wav", "seconds": 0.6, "hz": 490 }, + { "name": "probe_vo2_WA-Enemy-12L.wav", "seconds": 0.6, "hz": 500 }, + { "name": "probe_vo2_WA-Enemy-12.wav", "seconds": 0.6, "hz": 510 }, + { "name": "probe_vo2_WA-Enemy-12H.wav", "seconds": 0.6, "hz": 520 }, + { "name": "probe_vo2_WA-Enemy-3L.wav", "seconds": 0.6, "hz": 530 }, + { "name": "probe_vo2_WA-Enemy-3.wav", "seconds": 0.6, "hz": 540 }, + { "name": "probe_vo2_WA-Enemy-3H.wav", "seconds": 0.6, "hz": 550 }, + { "name": "probe_vo2_WA-Enemy-6L.wav", "seconds": 0.6, "hz": 560 }, + { "name": "probe_vo2_WA-Enemy-6.wav", "seconds": 0.6, "hz": 570 }, + { "name": "probe_vo2_WA-Enemy-6H.wav", "seconds": 0.6, "hz": 580 }, + { "name": "probe_vo2_WA-Enemy-9L.wav", "seconds": 0.6, "hz": 590 }, + { "name": "probe_vo2_WA-Enemy-9.wav", "seconds": 0.6, "hz": 600 }, + { "name": "probe_vo2_WA-Enemy-9H.wav", "seconds": 0.6, "hz": 610 }, + { "name": "probe_vo26_WA-Turret-A.wav", "seconds": 0.6, "hz": 180 }, + { "name": "probe_vo26_WA-HighDmg-A.wav", "seconds": 0.6, "hz": 190 }, + { "name": "probe_vo26_WA-Attack-A.wav", "seconds": 0.6, "hz": 200 }, + { "name": "probe_vo26_PR-DngrZn-A.wav", "seconds": 0.6, "hz": 210 }, + { "name": "probe_vo26_PR-EnemyDwn-A.wav", "seconds": 0.6, "hz": 220 }, + { "name": "probe_vo26_DI-LowDmg-A.wav", "seconds": 0.6, "hz": 230 }, + { "name": "probe_vo26_DI-MedDmg-A.wav", "seconds": 0.6, "hz": 240 }, + { "name": "probe_vo26_DI-HighDmg-A.wav", "seconds": 0.6, "hz": 250 }, + { "name": "probe_vo26_DA-Bail-A.wav", "seconds": 0.6, "hz": 260 }, + { "name": "probe_vo26_DA-NoBail-A.wav", "seconds": 0.6, "hz": 270 }, + { "name": "probe_vo26_DE-Bail-A.wav", "seconds": 0.6, "hz": 280 }, + { "name": "probe_vo26_DE-NoBail-A.wav", "seconds": 0.6, "hz": 290 }, + { "name": "probe_vo26_GL-AllyDwn-A.wav", "seconds": 0.6, "hz": 300 }, + { "name": "probe_vo26_GL-EnemyDwn-A.wav", "seconds": 0.6, "hz": 310 }, + { "name": "probe_vo26_GL-PlyrDwn-A.wav", "seconds": 0.6, "hz": 320 }, + { "name": "probe_vo26_TA-FailTail-A.wav", "seconds": 0.6, "hz": 330 }, + { "name": "probe_vo26_TA-FailShk-A.wav", "seconds": 0.6, "hz": 340 }, + { "name": "probe_vo26_TA-SucShk-A.wav", "seconds": 0.6, "hz": 350 }, + { "name": "probe_vo26_DS-Ally-A.wav", "seconds": 0.6, "hz": 360 }, + { "name": "probe_vo26_DI-LowDmg-B.wav", "seconds": 0.6, "hz": 370 } + ] +} diff --git a/CSVM.Tests/fixtures/synthetic/stock_loadouts.json b/CSVM.Tests/fixtures/synthetic/stock_loadouts.json new file mode 100644 index 000000000..7fcb35181 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/stock_loadouts.json @@ -0,0 +1,114 @@ +{ + "planes": { + "pprobe": { + "model": "probe_plane", + "display": "Probe Plane", + "guns": [ + {"slot": 1, "mount": "Probe Outer Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 2, "mount": "Probe Inner Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]} + ], + "hardpoints": {"count": 2, "stock": ["wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobefighter": { + "model": "player_pfighter", + "display": "Probe Fighter", + "guns": [ + {"slot": 1, "mount": "Fighter Wing Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 4, "mount": "Fighter Nose Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint1", "firepoint2"]} + ], + "hardpoints": {"count": 4, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobebrand": { + "model": "player_fbrand", + "display": "Probe Lancer", + "guns": [ + {"slot": 2, "mount": "Lancer Wing Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]}, + {"slot": 3, "mount": "Lancer Cowl Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint3", "firepoint4"]} + ], + "hardpoints": {"count": 2, "stock": ["wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobeavenger": { + "model": "player_avenger", + "display": "Probe Bulwark", + "guns": [ + {"slot": 1, "mount": "Bulwark Outer Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 2, "mount": "Bulwark Inner Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]}, + {"slot": 4, "mount": "Bulwark Nose Gun", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint1"]} + ], + "hardpoints": {"count": 6, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobehawk": { + "model": "player_bhawk", + "display": "Probe Kite", + "guns": [ + {"slot": 2, "mount": "Kite Wing Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]}, + {"slot": 4, "mount": "Kite Nose Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint1", "firepoint2"]} + ], + "hardpoints": {"count": 4, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobegyro": { + "model": "player_autogyro", + "display": "Probe Whirl", + "guns": [ + {"slot": 3, "mount": "Whirl Cowl Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint3", "firepoint4"]} + ], + "hardpoints": {"count": 2, "stock": ["wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobefury": { + "model": "player_fury", + "display": "Probe Gale", + "guns": [ + {"slot": 1, "mount": "Gale Outer Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 3, "mount": "Gale Cowl Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint3", "firepoint4"]} + ], + "hardpoints": {"count": 4, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobepeace": { + "model": "player_peacemaker", + "display": "Probe Warden", + "guns": [ + {"slot": 1, "mount": "Warden Outer Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 2, "mount": "Warden Inner Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]} + ], + "hardpoints": {"count": 6, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobewarhawk": { + "model": "player_warhawk", + "display": "Probe Hauler", + "guns": [ + {"slot": 2, "mount": "Hauler Wing Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint5", "firepoint6"]}, + {"slot": 4, "mount": "Hauler Nose Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint1", "firepoint2"]} + ], + "hardpoints": {"count": 8, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + }, + "pprobekestrel": { + "model": "player_kestrel", + "display": "Probe Heron", + "guns": [ + {"slot": 1, "mount": "Heron Outer Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint7", "firepoint8"]}, + {"slot": 3, "mount": "Heron Cowl Guns", "caliber": 30, "ammo": "slug", "weapon": "wep_probe_gun", + "markers": ["firepoint3", "firepoint4"]} + ], + "hardpoints": {"count": 4, "stock": ["wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket", "wep_probe_rocket"]} + } + } +} diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/ai.json b/CSVM.Tests/fixtures/synthetic/zrdr/ai.json new file mode 100644 index 000000000..47f819b2c --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/ai.json @@ -0,0 +1,48 @@ +[ + [ + "TURRET", + [ + [ + "TITLE", ["MSG_TUR_PROBE_REAR_G3"], + "CREATE_STANDALONE", [0], + "ACTIVATED", [1], + "HEALTHY_NODE", ["probe_kestrel_turret1"], + "PARTS", ["pturret", "pgun", "pfirepoint"], + "WEAPON", ["NAME", ["wep_probe_gun"], "AMMO", [7000], "DETECTION_RANGE", [520], "FIRE_RATE", [0.35]], + "INACCURACY", [4.5], + "ATTACK_INTERVAL", [3.5], + "BORED_INTERVAL", [2.5], + "PITCH", [15, 65], + "YAW", [125, 255], + "SOUNDS", ["CANNON", ["snd_probe_gunloop"]] + ], + [ + "TITLE", ["MSG_TUR_PROBE_DORSAL_G3"], + "CREATE_STANDALONE", [0], + "ACTIVATED", [1], + "HEALTHY_NODE", ["probe_brand_turret1"], + "PARTS", ["pturret", "pgun", "pfirepoint"], + "WEAPON", ["NAME", ["wep_probe_gun"], "AMMO", [7000], "DETECTION_RANGE", [460], "FIRE_RATE", [0.3]], + "INACCURACY", [5], + "ATTACK_INTERVAL", [4.5], + "BORED_INTERVAL", [2], + "PITCH", [-20, 70], + "YAW", [0, 0], + "SOUNDS", ["CANNON", ["snd_probe_gunloop"]] + ], + [ + "TITLE", ["MSG_TUR_PROBE_AA"], + "ACTIVATED", [0], + "NODES", [["aagun**"]], + "PARTS", ["turret", "gun", "firepoint"], + "WEAPON", ["NAME", ["wep_probe_gun"], "AMMO", [7000], "DETECTION_RANGE", [780], "FIRE_RATE", [0.8, 1.6]], + "INACCURACY", [9], + "ATTACK_INTERVAL", [3, 6], + "BORED_INTERVAL", [2, 4], + "PITCH", [5, 80], + "YAW", [0, 0], + "SOUNDS", ["CANNON", ["snd_probe_gunloop"]] + ] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/engines.json b/CSVM.Tests/fixtures/synthetic/zrdr/engines.json new file mode 100644 index 000000000..f4898125e --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/engines.json @@ -0,0 +1,29 @@ +[ + [1, "probe_engine_lo", 0.65], + [2, "probe_engine", 0.85], + [3, "probe_engine_hi", 1.0], + [10, "probe_hawk_engine", 0.95], + [11, "probe_hawk_engine_mid", 1.0], + [12, "probe_hawk_engine_top", 1.1], + [13, "probe_peace_engine", 1.0], + [14, "probe_peace_engine_mid", 1.08], + [15, "probe_peace_engine_top", 1.16], + [16, "probe_fury_engine", 1.02], + [17, "probe_fury_engine_mid", 1.1], + [18, "probe_fury_engine_top", 1.18], + [19, "probe_avenger_engine", 1.1], + [20, "probe_avenger_engine_mid", 1.2], + [21, "probe_avenger_engine_top", 1.3], + [28, "probe_kestrel_engine", 1.06], + [29, "probe_kestrel_engine_mid", 1.14], + [30, "probe_kestrel_engine_top", 1.24], + [31, "probe_brand_engine", 0.9], + [32, "probe_brand_engine_mid", 0.95], + [33, "probe_brand_engine_top", 1.05], + [34, "probe_warhawk_engine", 1.15], + [35, "probe_warhawk_engine_mid", 1.25], + [36, "probe_warhawk_engine_top", 1.35], + [37, "probe_gyro_engine", 0.7], + [38, "probe_gyro_engine_mid", 0.78], + [39, "probe_gyro_engine_top", 0.86] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/flame_ball.json b/CSVM.Tests/fixtures/synthetic/zrdr/flame_ball.json new file mode 100644 index 000000000..02e45caf7 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/flame_ball.json @@ -0,0 +1,35 @@ +[ + [ + "ANIMATION_DEFINITIONS", + [ + "ANIMATION_LIST", + [ + "ANIMATION_DEFINITION", + [ + "NAME", ["probe_flame_root"], + "ANIMATION_NAME", ["probe_flame_ball"], + "ACTIVATION", ["ON_CALL"], + "SEQUENCE_DEFINITION", + [ + "NAME", ["probe_flame_burst"], + "PUFFER_STATE", + [ + "NAME", ["fierypuffer"], + "ACTIVE_STATE", ["ACTIVE"], + "AT_NODE", ["probe_flame_root"], + "NUMBER", [5], + "TIME_INTERVAL", [0.15], + "SIZE_RANGE", [1.5, 3.5], + "LIFETIME_RANGE", [0.4, 0.75], + "GROWTH_FACTOR", [2], + "MIN_RANDOM_VELOCITY", [-2, 1, -2], + "MAX_RANDOM_VELOCITY", [2, 4, 2], + "TEXTURE_SEQUENCE", [[0, "smoke101"], [0.4, "smoke102"], [0.8, "smoke103"]] + ], + "PUFFER_STATE", ["NAME", ["fierypuffer"], "ACTIVE_STATE", ["INACTIVE"]] + ] + ] + ] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/player.json b/CSVM.Tests/fixtures/synthetic/zrdr/player.json new file mode 100644 index 000000000..e340c568a --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/player.json @@ -0,0 +1,44 @@ +[ + [ + "nom_gravity", [16.0], + "stall_mag", [1.1], + "crash", + [ + "bounce_factor", [0.5], + "armor_damage_range", [10.0, 150.0], + "health_damage_range", [10.0, 150.0] + ], + "min_ai_active_dist", [1800.0], + "sticky_bullet_catchup_rate", [4.0], + "sticky_bullet_forget_interval", [1.2], + "sticky_bullet_dist_factor", [0.0001], + "sticky_bullet_inaccuracy", [0.8], + "voiceover_volume_limiter", [0.45], + "warning_shot_sound", ["probe_warning_sg"], + "bullet_hit_sound", ["probe_ricochet_sg"], + "rattle", + [ + "sound", ["snd_probe_rattle"], + "speed_range", [1.05, 1.3], + "volume_range", [0.0, 1.0] + ], + "score_kill", [2.0], + "score_suicide", [-3.0], + "smokescreen_stun_range", [450.0], + "smokescreen_stun_angle", [160.0], + "smokescreen_stun_interval", [4.0], + "ai_skill_parameters", + [ + "daredevil_chance", [0.3, 0.9], + "sixth_sense_chance", [0.4, 0.8], + "sixth_sense_factor", [0.95, 1.05], + "dead_eye_angle", [5.0, 1.0], + "quick_draw_angle", [45.0, 80.0], + "quick_draw_chance", [0.1, 0.5], + "steady_hand_chance", [0.6, 0.1], + "stun_recovery_interval", [4.0, 1.0], + "talker_chance", [0.3, 0.9], + "constitution_chance", [0.4, 0.9] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/probe_world.json b/CSVM.Tests/fixtures/synthetic/zrdr/probe_world.json new file mode 100644 index 000000000..c0fb7a7cb --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/probe_world.json @@ -0,0 +1,52 @@ +[ + [ + "ANIMATION_DEFINITIONS", + [ + "ANIMATION_LIST", + [ + "ANIMATION_DEFINITION", + [ + "NAME", ["probe_shed"], + "ACTIVATION", ["WEAPON_HIT"], + "HEALTH", [60], + "PERSIST_LOG", ["ON"], + "RESET_STATE", + [ + "OBJECT_ACTIVE_STATE", ["NAME", ["healthy"], "STATE", ["ACTIVE"]], + "OBJECT_ACTIVE_STATE", ["NAME", ["destroyed"], "STATE", ["INACTIVE"]] + ], + "SEQUENCE_DEFINITION", + [ + "NAME", ["probe_shed_down"], + "OBJECT_ACTIVE_STATE", ["NAME", ["healthy"], "STATE", ["INACTIVE"]], + "OBJECT_ACTIVE_STATE", ["NAME", ["destroyed"], "STATE", ["ACTIVE"]] + ] + ], + "ANIMATION_DEFINITION", + [ + "NAME", ["probe_tank"], + "ACTIVATION", ["WEAPON_OR_COLLIDE_HIT"], + "HEALTH", [90], + "PERSIST_LOG", ["ON"], + "RESET_STATE", + [ + "OBJECT_ACTIVE_STATE", ["NAME", ["healthy"], "STATE", ["ACTIVE"]], + "OBJECT_ACTIVE_STATE", ["NAME", ["destroyed"], "STATE", ["INACTIVE"]] + ], + "SEQUENCE_DEFINITION", + [ + "NAME", ["probe_tank_down"], + "OBJECT_ACTIVE_STATE", ["NAME", ["healthy"], "STATE", ["INACTIVE"]], + "OBJECT_ACTIVE_STATE", ["NAME", ["destroyed"], "STATE", ["ACTIVE"]] + ], + "DAMAGE_SEQUENCE", + [ + "IF", ["ANIM_HEALTH", [60]], + "OBJECT_ROTATE_STATE", ["NAME", ["healthy"], "STATE", [0, 0, 4]], + "ENDIF" + ] + ] + ] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/pufftrails.json b/CSVM.Tests/fixtures/synthetic/zrdr/pufftrails.json new file mode 100644 index 000000000..9b1a05672 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/pufftrails.json @@ -0,0 +1,44 @@ +[ + [ + "ANIMATION_DEFINITIONS", + [ + "ANIMATION_LIST", + [ + "ANIMATION_DEFINITION", + [ + "NAME", ["probe_trail_root"], + "ANIMATION_NAME", ["probe_dense_trail"], + "ACTIVATION", ["ON_CALL"], + "SEQUENCE_DEFINITION", + [ + "NAME", ["probe_trail_on"], + "PUFFER_STATE", + [ + "NAME", ["smokepuffer"], + "ACTIVE_STATE", ["ACTIVE"], + "AT_NODE", ["probe_trail_root"], + "DISTANCE_INTERVAL", [3], + "SIZE_RANGE", [1, 2.5], + "LIFETIME_RANGE", [2, 3.5], + "GROWTH_FACTOR", [3], + "DEVIATION_DISTANCE", [0.6], + "TEXTURES", ["smoke101", "smoke102", "smoke103"], + "COLORS", [[0, 200, 150, 110, 0.8], [0.5, 90, 84, 80, 0.6], [1, 40, 40, 40, 0]] + ], + "PUFFER_STATE", + [ + "NAME", ["firepuffer"], + "ACTIVE_STATE", ["ACTIVE"], + "AT_NODE", ["probe_trail_root"], + "DISTANCE_INTERVAL", [0.8], + "SIZE_RANGE", [0.6, 1.4], + "LIFETIME_RANGE", [0.3, 0.6], + "DEVIATION_DISTANCE", [0.3], + "TEXTURES", ["smoke103"] + ] + ] + ] + ] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/shakes.json b/CSVM.Tests/fixtures/synthetic/zrdr/shakes.json new file mode 100644 index 000000000..0fb5f64f4 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/shakes.json @@ -0,0 +1,46 @@ +[ + "fire_bullet", + [ + "frequency", [12.0], + "damp", [10.0], + "sawtooth", [1], + "magnitude_factor", [0.0001] + ], + "bullet_impact", + [ + "frequency", [9.0], + "damp", [8.0], + "sawtooth", [0], + "magnitude_factor", [0.002] + ], + "missile_impact", + [ + "frequency", [3.0], + "damp", [5.0], + "sawtooth", [0], + "magnitude_factor", [0.0008], + "he_factor", [1.5] + ], + "explosion", + [ + "frequency", [2.5], + "damp", [4.0], + "sawtooth", [0], + "magnitude_factor", [0.0005] + ], + "high_speed", + [ + "frequency", [12.0], + "damp", [10.0], + "sawtooth", [1], + "min_speed", [1.15], + "magnitude_quotient", [90.0] + ], + "nitro", + [ + "frequency", [3.0], + "damp", [4.0], + "sawtooth", [1], + "magnitude", [0.03] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/sounds.json b/CSVM.Tests/fixtures/synthetic/zrdr/sounds.json new file mode 100644 index 000000000..565750e8c --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/sounds.json @@ -0,0 +1,166 @@ +[ + [ + "SETS", + [ + "probe_flight", + [ + ["snd_probe_engine", "probe_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [120, 900]], + ["snd_probe_fighter_engine", "probe_fighter_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [100, 800]], + ["snd_probe_brand_engine", "probe_brand_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [110, 850]], + ["snd_probe_avenger_engine", "probe_avenger_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [140, 1000]], + ["snd_probe_hawk_engine", "probe_hawk_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [105, 820]], + ["snd_probe_gyro_engine", "probe_gyro_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [80, 700]], + ["snd_probe_fury_engine", "probe_fury_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [115, 880]], + ["snd_probe_peace_engine", "probe_peace_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [125, 920]], + ["snd_probe_warhawk_engine", "probe_warhawk_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [150, 1050]], + ["snd_probe_kestrel_engine", "probe_kestrel_engine.wav", "LOOPED", "3D", "FREQUENCY", "SFX", "RANGE", [135, 960]], + ["snd_probe_engine_cp", "probe_engine_cp.wav", "LOOPED", "SFX", "VOLUME", [0.8]], + ["snd_probe_engine_damaged", "probe_engine_damaged.wav", "LOOPED", "3D", "SFX", "RANGE", [100, 500]], + ["snd_probe_rattle", "probe_rattle.wav", "LOOPED", "SFX", "VOLUME", [0.6]], + ["snd_propstart", "probe_propstart.wav", "3D", "SFX", "RANGE", [40, 400]] + ], + "probe_weapons", + [ + ["snd_probe_gunloop", "probe_gunloop.wav", "LOOPED", "3D", "SFX", "RANGE", [25, 180]], + ["snd_probe_emptyclip", "probe_emptyclip.wav", "3D", "SFX", "RANGE", [60, 600]], + ["snd_probe_fire", "probe_fire.wav", "3D", "SFX", "RANGE", [30, 300]], + ["snd_probe_launch", "probe_launch.wav", "3D", "SFX", "RANGE", [50, 600]], + ["snd_probe_hit", "probe_hit.wav", "3D", "SFX", "RANGE", [20, 250]], + ["snd_probe_boom", "probe_boom.wav", "3D", "SFX", "RANGE", [150, 1500], "VOLUME", [0.9]], + ["snd_probe_pass1", "probe_pass1.wav", "SFX"], + ["snd_probe_pass2", "probe_pass2.wav", "SFX"], + ["snd_probe_ricochet1", "probe_ricochet1.wav", "3D", "SFX", "RANGE", [10, 120]], + ["snd_probe_ricochet2", "probe_ricochet2.wav", "3D", "SFX", "RANGE", [10, 120]], + ["snd_probe_windowhit1", "probe_windowhit1.wav", "SFX"], + ["snd_probe_windowhit2", "probe_windowhit2.wav", "SFX"] + ], + "probe_radio", + [ + ["snd_probe_radio_line", "probe_radio_line.wav", "QUEUE", [45]], + ["snd_probe_radio_bark", "probe_radio_bark.wav", "QUEUE", [0.5]] + ], + "probe_voice", + [ + ["snd_id2_WA-Turret-A", "probe_vo2_WA-Turret-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-HighDmg-A", "probe_vo2_WA-HighDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Attack-A", "probe_vo2_WA-Attack-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_PR-DngrZn-A", "probe_vo2_PR-DngrZn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_PR-EnemyDwn-A", "probe_vo2_PR-EnemyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DI-LowDmg-A", "probe_vo2_DI-LowDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DI-MedDmg-A", "probe_vo2_DI-MedDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DI-HighDmg-A", "probe_vo2_DI-HighDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DA-Bail-A", "probe_vo2_DA-Bail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DA-NoBail-A", "probe_vo2_DA-NoBail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DE-Bail-A", "probe_vo2_DE-Bail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DE-NoBail-A", "probe_vo2_DE-NoBail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_GL-AllyDwn-A", "probe_vo2_GL-AllyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_GL-EnemyDwn-A", "probe_vo2_GL-EnemyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_GL-PlyrDwn-A", "probe_vo2_GL-PlyrDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_TA-FailTail-A", "probe_vo2_TA-FailTail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_TA-FailShk-A", "probe_vo2_TA-FailShk-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_TA-SucShk-A", "probe_vo2_TA-SucShk-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DS-Ally-A", "probe_vo2_DS-Ally-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_DI-LowDmg-B", "probe_vo2_DI-LowDmg-B.wav", "PURGEABLE", "QUEUE", [0.5], "OPTIONAL"], + ["snd_id2_WA-Enemy-12L", "probe_vo2_WA-Enemy-12L.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-12", "probe_vo2_WA-Enemy-12.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-12H", "probe_vo2_WA-Enemy-12H.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-3L", "probe_vo2_WA-Enemy-3L.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-3", "probe_vo2_WA-Enemy-3.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-3H", "probe_vo2_WA-Enemy-3H.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-6L", "probe_vo2_WA-Enemy-6L.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-6", "probe_vo2_WA-Enemy-6.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-6H", "probe_vo2_WA-Enemy-6H.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-9L", "probe_vo2_WA-Enemy-9L.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-9", "probe_vo2_WA-Enemy-9.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id2_WA-Enemy-9H", "probe_vo2_WA-Enemy-9H.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_WA-Turret-A", "probe_vo26_WA-Turret-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_WA-HighDmg-A", "probe_vo26_WA-HighDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_WA-Attack-A", "probe_vo26_WA-Attack-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_PR-DngrZn-A", "probe_vo26_PR-DngrZn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_PR-EnemyDwn-A", "probe_vo26_PR-EnemyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DI-LowDmg-A", "probe_vo26_DI-LowDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DI-MedDmg-A", "probe_vo26_DI-MedDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DI-HighDmg-A", "probe_vo26_DI-HighDmg-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DA-Bail-A", "probe_vo26_DA-Bail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DA-NoBail-A", "probe_vo26_DA-NoBail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DE-Bail-A", "probe_vo26_DE-Bail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DE-NoBail-A", "probe_vo26_DE-NoBail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_GL-AllyDwn-A", "probe_vo26_GL-AllyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_GL-EnemyDwn-A", "probe_vo26_GL-EnemyDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_GL-PlyrDwn-A", "probe_vo26_GL-PlyrDwn-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_TA-FailTail-A", "probe_vo26_TA-FailTail-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_TA-FailShk-A", "probe_vo26_TA-FailShk-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_TA-SucShk-A", "probe_vo26_TA-SucShk-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DS-Ally-A", "probe_vo26_DS-Ally-A.wav", "PURGEABLE", "QUEUE", [0.5]], + ["snd_id26_DI-LowDmg-B", "probe_vo26_DI-LowDmg-B.wav", "PURGEABLE", "QUEUE", [0.5], "OPTIONAL"] + ], + "probe_music", + [ + ["snd_music_splash", "music_splash_probe.wav", "MUSIC"], + ["snd_probe_music_prebattle1", "music_prebattle_probe1.wav", "MUSIC"], + ["snd_probe_music_prebattle2", "music_prebattle_probe2.wav", "MUSIC"], + ["snd_probe_music_battle1", "music_battle_probe1.wav", "MUSIC", "LOOPED"], + ["snd_probe_music_battle2", "music_battle_probe2.wav", "MUSIC", "LOOPED"], + ["snd_probe_music_primaryobj1", "music_primaryobj_probe1.wav", "MUSIC"], + ["snd_probe_music_primaryobj2", "music_primaryobj_probe2.wav", "MUSIC"], + ["snd_probe_music_secondaryobj1", "music_secondaryobj_probe1.wav", "MUSIC"], + ["snd_probe_music_secondaryobj2", "music_secondaryobj_probe2.wav", "MUSIC"], + ["snd_probe_music_tertiaryobj1", "music_tertiaryobj_probe1.wav", "MUSIC"], + ["snd_probe_music_tertiaryobj2", "music_tertiaryobj_probe2.wav", "MUSIC"], + ["snd_probe_music_battlesuccess", "music_battlesuccess_probe.wav", "MUSIC"], + ["snd_probe_music_missionsuccess", "music_missionsuccess_probe.wav", "MUSIC"] + ] + ], + "SOUND_GROUPS", + [ + ["probe_warning_sg", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_pass1"], ["snd_probe_pass2"]], + ["probe_ricochet_sg", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_ricochet1"], ["snd_probe_ricochet2"]], + ["window_hit_sg", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_windowhit1"], ["snd_probe_windowhit2"]], + ["snd_WA-Turret-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_WA-Turret-A"]], + ["snd_WA-HighDmg-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_WA-HighDmg-A"]], + ["snd_WA-Attack-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_WA-Attack-A"]], + ["snd_PR-DngrZn-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_PR-DngrZn-A"]], + ["snd_PR-EnemyDwn-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_PR-EnemyDwn-A"]], + ["snd_DI-LowDmg-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DI-LowDmg-A"], ["snd_id2_DI-LowDmg-B"]], + ["snd_DI-MedDmg-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DI-MedDmg-A"]], + ["snd_DI-HighDmg-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DI-HighDmg-A"]], + ["snd_DA-Bail-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DA-Bail-A"]], + ["snd_DA-NoBail-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DA-NoBail-A"]], + ["snd_DE-Bail-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DE-Bail-A"]], + ["snd_DE-NoBail-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DE-NoBail-A"]], + ["snd_GL-AllyDwn-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_GL-AllyDwn-A"]], + ["snd_GL-EnemyDwn-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_GL-EnemyDwn-A"]], + ["snd_GL-PlyrDwn-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_GL-PlyrDwn-A"]], + ["snd_TA-FailTail-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_TA-FailTail-A"]], + ["snd_TA-FailShk-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_TA-FailShk-A"]], + ["snd_TA-SucShk-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_TA-SucShk-A"]], + ["snd_DS-Ally-A_id2_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id2_DS-Ally-A"]], + ["snd_WA-Turret-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_WA-Turret-A"]], + ["snd_WA-HighDmg-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_WA-HighDmg-A"]], + ["snd_WA-Attack-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_WA-Attack-A"]], + ["snd_PR-DngrZn-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_PR-DngrZn-A"]], + ["snd_PR-EnemyDwn-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_PR-EnemyDwn-A"]], + ["snd_DI-LowDmg-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DI-LowDmg-A"], ["snd_id26_DI-LowDmg-B"]], + ["snd_DI-MedDmg-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DI-MedDmg-A"]], + ["snd_DI-HighDmg-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DI-HighDmg-A"]], + ["snd_DA-Bail-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DA-Bail-A"]], + ["snd_DA-NoBail-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DA-NoBail-A"]], + ["snd_DE-Bail-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DE-Bail-A"]], + ["snd_DE-NoBail-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DE-NoBail-A"]], + ["snd_GL-AllyDwn-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_GL-AllyDwn-A"]], + ["snd_GL-EnemyDwn-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_GL-EnemyDwn-A"]], + ["snd_GL-PlyrDwn-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_GL-PlyrDwn-A"]], + ["snd_TA-FailTail-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_TA-FailTail-A"]], + ["snd_TA-FailShk-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_TA-FailShk-A"]], + ["snd_TA-SucShk-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_TA-SucShk-A"]], + ["snd_DS-Ally-A_id26_random", "DYNAMIC_WEIGHTS", 0.5, ["snd_id26_DS-Ally-A"]], + ["music_prebattle_sg", "MUSIC", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_music_prebattle1"], ["snd_probe_music_prebattle2"]], + ["music_battle_sg", "MUSIC", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_music_battle1"], ["snd_probe_music_battle2"]], + ["music_primaryobj_sg", "MUSIC", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_music_primaryobj1"], ["snd_probe_music_primaryobj2"]], + ["music_secondaryobj_sg", "MUSIC", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_music_secondaryobj1"], ["snd_probe_music_secondaryobj2"]], + ["music_tertiaryobj_sg", "MUSIC", "DYNAMIC_WEIGHTS", 0.5, ["snd_probe_music_tertiaryobj1"], ["snd_probe_music_tertiaryobj2"]], + ["music_battlesuccess_sg", "MUSIC", ["snd_probe_music_battlesuccess"]], + ["music_missionsuccess_sg", "MUSIC", ["snd_probe_music_missionsuccess"]] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/vehicle.json b/CSVM.Tests/fixtures/synthetic/zrdr/vehicle.json new file mode 100644 index 000000000..f781ec243 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/vehicle.json @@ -0,0 +1,689 @@ +[ + [ + "player_airplane", + [ + "flight_ceiling", [2200], + "engine_sound", ["snd_probe_engine"], + "cockpit_engine_sound", ["snd_probe_engine_cp"], + "damaged_engine_sound", [["snd_probe_engine_damaged", 0.6, 0.9]] + ], + "pprobe", + [ + "kind_of", ["player_airplane"], + "nodename", ["probe_plane"], + "title", ["MSG_PROBE_PLANE"], + "engine", [2], + "fuel", [90], + "dynamics", + [ + "pitch_torque", [2.0], + "roll_torque", [5.0], + "rudder_torque", [1.2], + "return_rate", [2.5], + "ang_momentum_damp", [4.5], + "rec_moments_inertia", [0.9, 0.7, 1.1], + "fd_speed", [120], + "drag_factor", [1.0], + "veh_weight", [3000], + "ref_area", [300] + ], + "destroyable_parts", + [ + ["nose", 30, 30, "critical", "engine", + "injure_anims", [[0.25, "pdpanel7", "probe_plane"]]], + ["tail", 20, 20, + "injure_anims", [[0.25, "pdpanel8", "probe_plane"]]], + ["leftwing", 25, 25, + "injure_anims", [[0.6, "pdpanel3", "probe_plane"], [0.35, "pdpanel2", "probe_plane"], [0.1, "pdpanel5", "probe_plane"]]], + ["rightwing", 25, 25, + "injure_anims", [[0.6, "pdpanel4", "probe_plane"], [0.35, "pdpanel1", "probe_plane"], [0.1, "pdpanel6", "probe_plane"]]] + ], + "collision", + [ + [0.0, 0.0, -4.6], + [0.0, -0.1, 4.5], + [5.0, -0.1, 0.5], + [0.0, 0.7, -0.5], + [-5.0, -0.1, -0.5], + [0.0, -0.7, 3.0] + ] + ], + "probe", + [ + "title", ["MSG_PROBE_PLANE"], + "armor", [60], + "health", [60], + "dare_devil", [5], + "natural_touch", [5], + "sixth_sense", [5], + "dead_eye", [5], + "quick_draw", [5], + "steady_hand", [5], + "stun_recovery", [5], + "talker", [5], + "constitution", [5], + "weapons", + [ + ["wep_probe_gun", 400, 0.5, 0, 800], + ["wep_probe_rocket", 4, 3, 150, 1500] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "pprobefighter", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_pfighter"], + "title", ["MSG_PROBE_FIGHTER"], + "engine_sound", ["snd_probe_fighter_engine"], + "engine", [3], + "fuel", [70], + "dynamics", + [ + "pitch_torque", [2.7], + "roll_torque", [6.6], + "rudder_torque", [1.6], + "return_rate", [3.3], + "ang_momentum_damp", [5.4], + "rec_moments_inertia", [0.75, 0.55, 1.05], + "fd_speed", [135], + "drag_factor", [0.9], + "veh_weight", [2600], + "ref_area", [260] + ], + "destroyable_parts", + [ + ["nose", 22, 22, "critical", "engine", + "injure_anims", [[0.3, "pdpanel7", "player_pfighter"]]], + ["tail", 16, 16, + "injure_anims", [[0.3, "pdpanel8", "player_pfighter"]]], + ["leftwing", 18, 18, + "injure_anims", [[0.55, "pdpanel3", "player_pfighter"], [0.3, "pdpanel2", "player_pfighter"], [0.1, "pdpanel5", "player_pfighter"]]], + ["rightwing", 18, 18, + "injure_anims", [[0.55, "pdpanel4", "player_pfighter"], [0.3, "pdpanel1", "player_pfighter"], [0.1, "pdpanel6", "player_pfighter"]]] + ], + "collision", + [ + [0.0, 0.0, -3.5], + [0.0, -0.1, 3.6], + [6.8, -0.2, -0.2], + [0.0, 0.8, -0.2], + [-6.8, -0.2, -0.2], + [0.0, -0.5, 2.0] + ] + ], + "probefighter", + [ + "title", ["MSG_PROBE_FIGHTER"], + "mode", ["jet"], + "armor", [45], + "health", [45], + "dare_devil", [7], + "natural_touch", [6], + "sixth_sense", [4], + "dead_eye", [6], + "quick_draw", [7], + "steady_hand", [4], + "stun_recovery", [6], + "talker", [3], + "constitution", [4], + "weapons", + [ + ["wep_probe_gun", 300, 0.4, 0, 700], + ["wep_probe_rocket", 2, 4, 200, 1400] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "wingman", + [ + "kind_of", ["probefighter"], + "mode", ["wingman"], + "dare_devil", [3], + "steady_hand", [7] + ], + "pprobebrand", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_fbrand"], + "title", ["MSG_PROBE_BRAND"], + "turrets", ["thirdp", [["title", ["MSG_TUR_PROBE_DORSAL_G3"], "node", ["probe_brand_turret1"]]]], + "engine_sound", ["snd_probe_brand_engine"], + "engine", [31], + "fuel", [80], + "dynamics", + [ + "pitch_torque", [2.3], + "roll_torque", [5.6], + "rudder_torque", [1.4], + "return_rate", [2.9], + "ang_momentum_damp", [4.9], + "rec_moments_inertia", [0.85, 0.6, 1.15], + "fd_speed", [128], + "drag_factor", [0.85], + "veh_weight", [2800], + "ref_area", [240] + ], + "destroyable_parts", + [ + ["nose", 26, 26, "critical", "engine", + "injure_anims", [[0.28, "pdpanel7", "player_fbrand"]]], + ["tail", 18, 18, + "injure_anims", [[0.28, "pdpanel8", "player_fbrand"]]], + ["leftwing", 20, 20, + "injure_anims", [[0.5, "pdpanel3", "player_fbrand"], [0.3, "pdpanel2", "player_fbrand"], [0.12, "pdpanel5", "player_fbrand"]]], + ["rightwing", 20, 20, + "injure_anims", [[0.5, "pdpanel4", "player_fbrand"], [0.3, "pdpanel1", "player_fbrand"], [0.12, "pdpanel6", "player_fbrand"]]] + ], + "collision", + [ + [0.0, 0.0, -4.5], + [0.0, -0.1, 4.7], + [5.4, -0.2, -0.3], + [0.0, 0.7, -0.3], + [-5.4, -0.2, -0.3], + [0.0, -0.45, 2.6] + ] + ], + "probebrand", + [ + "title", ["MSG_PROBE_BRAND"], + "mode", ["jet"], + "turrets", ["thirdp", [["title", ["MSG_TUR_PROBE_DORSAL_G3"], "node", ["probe_brand_turret1"]]]], + "armor", [50], + "health", [50], + "dare_devil", [6], + "natural_touch", [5], + "sixth_sense", [6], + "dead_eye", [5], + "quick_draw", [6], + "steady_hand", [5], + "stun_recovery", [5], + "talker", [4], + "constitution", [5], + "weapons", + [ + ["wep_probe_gun", 350, 0.45, 0, 750], + ["wep_probe_rocket", 2, 5, 180, 1300] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "pprobeavenger", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_avenger"], + "title", ["MSG_PROBE_AVENGER"], + "engine_sound", ["snd_probe_avenger_engine"], + "engine", [19], + "fuel", [110], + "dynamics", + [ + "pitch_torque", [1.8], + "roll_torque", [4.4], + "rudder_torque", [1.1], + "return_rate", [2.2], + "ang_momentum_damp", [4.2], + "rec_moments_inertia", [1.0, 0.8, 1.25], + "fd_speed", [112], + "drag_factor", [1.1], + "veh_weight", [3600], + "ref_area", [340] + ], + "destroyable_parts", + [ + ["nose", 34, 34, "critical", "engine", + "injure_anims", [[0.22, "pdpanel7", "player_avenger"]]], + ["tail", 24, 24, + "injure_anims", [[0.22, "pdpanel8", "player_avenger"]]], + ["leftwing", 28, 28, + "injure_anims", [[0.6, "pdpanel3", "player_avenger"], [0.35, "pdpanel2", "player_avenger"], [0.1, "pdpanel5", "player_avenger"]]], + ["rightwing", 28, 28, + "injure_anims", [[0.6, "pdpanel4", "player_avenger"], [0.35, "pdpanel1", "player_avenger"], [0.1, "pdpanel6", "player_avenger"]]] + ], + "collision", + [ + [0.0, 0.0, -4.0], + [0.0, -0.1, 4.1], + [8.2, -0.25, -0.2], + [0.0, 1.0, -0.2], + [-8.2, -0.25, -0.2], + [0.0, -0.6, 2.3] + ] + ], + "probeavenger", + [ + "title", ["MSG_PROBE_AVENGER"], + "mode", ["jet"], + "armor", [70], + "health", [70], + "dare_devil", [4], + "natural_touch", [4], + "sixth_sense", [5], + "dead_eye", [7], + "quick_draw", [4], + "steady_hand", [6], + "stun_recovery", [7], + "talker", [5], + "constitution", [7], + "weapons", + [ + ["wep_probe_gun", 450, 0.55, 0, 850], + ["wep_probe_rocket", 6, 3, 150, 1600] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "pprobehawk", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_bhawk"], + "title", ["MSG_PROBE_HAWK"], + "engine_sound", ["snd_probe_hawk_engine"], + "engine", [10], + "fuel", [75], + "dynamics", + [ + "pitch_torque", [2.5], + "roll_torque", [6.1], + "rudder_torque", [1.5], + "return_rate", [3.1], + "ang_momentum_damp", [5.1], + "rec_moments_inertia", [0.8, 0.58, 1.08], + "fd_speed", [126], + "drag_factor", [0.95], + "veh_weight", [2750], + "ref_area", [275] + ], + "destroyable_parts", + [ + ["nose", 24, 24, "critical", "engine", + "injure_anims", [[0.27, "pdpanel7", "player_bhawk"]]], + ["tail", 17, 17, + "injure_anims", [[0.27, "pdpanel8", "player_bhawk"]]], + ["leftwing", 19, 19, + "injure_anims", [[0.52, "pdpanel3", "player_bhawk"], [0.32, "pdpanel2", "player_bhawk"], [0.11, "pdpanel5", "player_bhawk"]]], + ["rightwing", 19, 19, + "injure_anims", [[0.52, "pdpanel4", "player_bhawk"], [0.32, "pdpanel1", "player_bhawk"], [0.11, "pdpanel6", "player_bhawk"]]] + ], + "collision", + [ + [0.0, 0.0, -3.15], + [0.0, -0.1, 3.24], + [6.46, -0.2, -0.18], + [0.0, 0.8, -0.18], + [-6.46, -0.2, -0.18], + [0.0, -0.5, 1.8] + ] + ], + "probehawk", + [ + "title", ["MSG_PROBE_HAWK"], + "mode", ["jet"], + "armor", [48], + "health", [48], + "dare_devil", [6], + "natural_touch", [7], + "sixth_sense", [5], + "dead_eye", [6], + "quick_draw", [5], + "steady_hand", [5], + "stun_recovery", [4], + "talker", [6], + "constitution", [5], + "weapons", + [ + ["wep_probe_gun", 380, 0.42, 0, 760], + ["wep_probe_rocket", 3, 4, 170, 1350] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "wbloodhawk", + [ + "kind_of", ["probehawk"], + "mode", ["wingman"], + "target_bias", [-60], + "struct_bias", [-180], + "dare_devil", [4], + "steady_hand", [6] + ], + "pprobegyro", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_autogyro"], + "title", ["MSG_PROBE_GYRO"], + "engine_sound", ["snd_probe_gyro_engine"], + "engine", [37], + "fuel", [60], + "is_autogyro", + "spin_props_anim", ["agyro_rotors"], + "dynamics", + [ + "pitch_torque", [2.9], + "roll_torque", [4.9], + "rudder_torque", [21.0], + "return_rate", [1.7], + "ang_momentum_damp", [4.6], + "rec_moments_inertia", [0.7, 0.9, 0.95], + "fd_speed", [98], + "drag_factor", [1.25], + "veh_weight", [2100], + "ref_area", [230] + ], + "destroyable_parts", + [ + ["nose", 20, 20, "critical", "engine", + "injure_anims", [[0.32, "pdpanel7", "player_autogyro"]]], + ["tail", 14, 14, + "injure_anims", [[0.32, "pdpanel8", "player_autogyro"]]], + ["leftwing", 15, 15, + "injure_anims", [[0.58, "pdpanel3", "player_autogyro"], [0.34, "pdpanel2", "player_autogyro"], [0.13, "pdpanel5", "player_autogyro"]]], + ["rightwing", 15, 15, + "injure_anims", [[0.58, "pdpanel4", "player_autogyro"], [0.34, "pdpanel1", "player_autogyro"], [0.13, "pdpanel6", "player_autogyro"]]] + ], + "collision", + [ + [0.0, 0.0, -2.8], + [0.0, -0.12, 2.88], + [4.76, -0.23, -0.16], + [0.0, 0.92, -0.16], + [-4.76, -0.23, -0.16], + [0.0, -0.58, 1.6] + ] + ], + "probegyro", + [ + "title", ["MSG_PROBE_GYRO"], + "mode", ["jet"], + "spin_props_anim", ["agyro_rotors"], + "armor", [38], + "health", [38], + "dare_devil", [4], + "natural_touch", [5], + "sixth_sense", [7], + "dead_eye", [4], + "quick_draw", [5], + "steady_hand", [6], + "stun_recovery", [5], + "talker", [7], + "constitution", [4], + "weapons", + [ + ["wep_probe_gun", 260, 0.5, 0, 650], + ["wep_probe_rocket", 2, 5, 160, 1200] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "autogyro", + [ + "kind_of", ["probegyro"], + "title", ["MSG_PROBE_GYRO"], + "talker", [3] + ], + "pprobefury", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_fury"], + "title", ["MSG_PROBE_FURY"], + "engine_sound", ["snd_probe_fury_engine"], + "engine", [16], + "fuel", [85], + "dynamics", + [ + "pitch_torque", [2.2], + "roll_torque", [5.3], + "rudder_torque", [1.3], + "return_rate", [2.7], + "ang_momentum_damp", [4.7], + "rec_moments_inertia", [0.88, 0.64, 1.18], + "fd_speed", [131], + "drag_factor", [0.88], + "veh_weight", [3100], + "ref_area", [290] + ], + "destroyable_parts", + [ + ["nose", 34, 8, "critical", "engine", + "injure_anims", [[0.24, "pdpanel7", "player_fury"]]], + ["tail", 24, 6, + "injure_anims", [[0.24, "pdpanel8", "player_fury"]]], + ["leftwing", 28, 7, + "injure_anims", [[0.57, "pdpanel3", "player_fury"], [0.33, "pdpanel2", "player_fury"], [0.09, "pdpanel5", "player_fury"]]], + ["rightwing", 28, 7, + "injure_anims", [[0.57, "pdpanel4", "player_fury"], [0.33, "pdpanel1", "player_fury"], [0.09, "pdpanel6", "player_fury"]]] + ], + "collision", + [ + [0.0, 0.0, -3.85], + [0.0, -0.1, 3.96], + [7.14, -0.19, -0.22], + [0.0, 0.76, -0.22], + [-7.14, -0.19, -0.22], + [0.0, -0.48, 2.2] + ] + ], + "probefury", + [ + "title", ["MSG_PROBE_FURY"], + "mode", ["jet"], + "injure_anims", [[0.93, "random_remote_damage"], [0.8, "random_remote_damage"], [0.6, "random_remote_damage"], [0.36, "pfsmoketrail"], [0.25, "random_remote_damage"]], + "armor", [58], + "health", [58], + "dare_devil", [5], + "natural_touch", [6], + "sixth_sense", [6], + "dead_eye", [7], + "quick_draw", [6], + "steady_hand", [4], + "stun_recovery", [6], + "talker", [4], + "constitution", [6], + "weapons", + [ + ["wep_probe_gun", 420, 0.48, 0, 820], + ["wep_probe_rocket", 4, 4, 190, 1450] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "pprobepeace", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_peacemaker"], + "title", ["MSG_PROBE_PEACE"], + "engine_sound", ["snd_probe_peace_engine"], + "engine", [13], + "fuel", [95], + "dynamics", + [ + "pitch_torque", [2.1], + "roll_torque", [4.7], + "rudder_torque", [1.25], + "return_rate", [2.6], + "ang_momentum_damp", [4.4], + "rec_moments_inertia", [0.92, 0.72, 1.2], + "fd_speed", [118], + "drag_factor", [1.05], + "veh_weight", [3300], + "ref_area", [315] + ], + "destroyable_parts", + [ + ["nose", 31, 31, "critical", "engine", + "injure_anims", [[0.23, "pdpanel7", "player_peacemaker"]]], + ["tail", 21, 21, + "injure_anims", [[0.23, "pdpanel8", "player_peacemaker"]]], + ["leftwing", 26, 26, + "injure_anims", [[0.62, "pdpanel3", "player_peacemaker"], [0.36, "pdpanel2", "player_peacemaker"], [0.12, "pdpanel5", "player_peacemaker"]]], + ["rightwing", 26, 26, + "injure_anims", [[0.62, "pdpanel4", "player_peacemaker"], [0.36, "pdpanel1", "player_peacemaker"], [0.12, "pdpanel6", "player_peacemaker"]]] + ], + "collision", + [ + [0.0, 0.0, -3.33], + [0.0, -0.1, 3.42], + [7.82, -0.21, -0.19], + [0.0, 0.84, -0.19], + [-7.82, -0.21, -0.19], + [0.0, -0.53, 1.9] + ] + ], + "probepeace", + [ + "title", ["MSG_PROBE_PEACE"], + "mode", ["jet"], + "armor", [62], + "health", [62], + "dare_devil", [3], + "natural_touch", [5], + "sixth_sense", [6], + "dead_eye", [5], + "quick_draw", [4], + "steady_hand", [7], + "stun_recovery", [6], + "talker", [5], + "constitution", [6], + "weapons", + [ + ["wep_probe_gun", 470, 0.52, 0, 880], + ["wep_probe_rocket", 5, 3, 160, 1550] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "pprobewarhawk", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_warhawk"], + "title", ["MSG_PROBE_WARHAWK"], + "engine_sound", ["snd_probe_warhawk_engine"], + "engine", [34], + "fuel", [120], + "dynamics", + [ + "pitch_torque", [1.7], + "roll_torque", [4.1], + "rudder_torque", [1.0], + "return_rate", [2.1], + "ang_momentum_damp", [4.0], + "rec_moments_inertia", [1.05, 0.85, 1.3], + "fd_speed", [108], + "drag_factor", [1.15], + "veh_weight", [3900], + "ref_area", [360] + ], + "destroyable_parts", + [ + ["nose", 36, 36, "critical", "engine", + "injure_anims", [[0.21, "pdpanel7", "player_warhawk"]]], + ["tail", 26, 26, + "injure_anims", [[0.21, "pdpanel8", "player_warhawk"]]], + ["leftwing", 30, 30, + "injure_anims", [[0.63, "pdpanel3", "player_warhawk"], [0.37, "pdpanel2", "player_warhawk"], [0.08, "pdpanel5", "player_warhawk"]]], + ["rightwing", 30, 30, + "injure_anims", [[0.63, "pdpanel4", "player_warhawk"], [0.37, "pdpanel1", "player_warhawk"], [0.08, "pdpanel6", "player_warhawk"]]] + ], + "collision", + [ + [0.0, 0.0, -4.38], + [0.0, -0.12, 4.5], + [8.84, -0.24, -0.25], + [0.0, 0.96, -0.25], + [-8.84, -0.24, -0.25], + [0.0, -0.6, 2.5] + ] + ], + "probewarhawk", + [ + "title", ["MSG_PROBE_WARHAWK"], + "mode", ["jet"], + "armor", [74], + "health", [74], + "dare_devil", [4], + "natural_touch", [4], + "sixth_sense", [5], + "dead_eye", [6], + "quick_draw", [5], + "steady_hand", [7], + "stun_recovery", [7], + "talker", [4], + "constitution", [7], + "weapons", + [ + ["wep_probe_gun", 520, 0.58, 0, 900], + ["wep_probe_rocket", 6, 3, 140, 1650] + ], + "collision", [[0.0, 0.0, 0.0]] + ], + "bhatwarhawk", + [ + "kind_of", ["probewarhawk"], + "title", ["MSG_PROBE_WARHAWK"], + "weapons", + [ + ["wep_probe_zeptorp", 6, 3, 320, 950], + ["wep_probe_gun", 560, 0.5, 1, 880] + ] + ], + "pprobekestrel", + [ + "kind_of", ["player_airplane"], + "nodename", ["player_kestrel"], + "title", ["MSG_PROBE_KESTREL"], + "turrets", ["thirdp", [["title", ["MSG_TUR_PROBE_REAR_G3"], "node", ["probe_kestrel_turret1"]]]], + "engine_sound", ["snd_probe_kestrel_engine"], + "engine", [28], + "fuel", [100], + "dynamics", + [ + "pitch_torque", [1.95], + "roll_torque", [4.6], + "rudder_torque", [1.15], + "return_rate", [2.3], + "ang_momentum_damp", [4.3], + "rec_moments_inertia", [0.98, 0.78, 1.22], + "fd_speed", [114], + "drag_factor", [1.08], + "veh_weight", [3450], + "ref_area", [330] + ], + "destroyable_parts", + [ + ["nose", 33, 33, "critical", "engine", + "injure_anims", [[0.22, "pdpanel7", "player_kestrel"]]], + ["tail", 23, 23, + "injure_anims", [[0.22, "pdpanel8", "player_kestrel"]]], + ["leftwing", 27, 27, + "injure_anims", [[0.61, "pdpanel3", "player_kestrel"], [0.35, "pdpanel2", "player_kestrel"], [0.11, "pdpanel5", "player_kestrel"]]], + ["rightwing", 27, 27, + "injure_anims", [[0.61, "pdpanel4", "player_kestrel"], [0.35, "pdpanel1", "player_kestrel"], [0.11, "pdpanel6", "player_kestrel"]]] + ], + "collision", + [ + [0.0, 0.0, -4.03], + [0.0, -0.11, 4.14], + [7.48, -0.22, -0.23], + [0.0, 0.88, -0.23], + [-7.48, -0.22, -0.23], + [0.0, -0.55, 2.3] + ] + ], + "probekestrel", + [ + "title", ["MSG_PROBE_KESTREL"], + "mode", ["jet"], + "turrets", ["thirdp", [["title", ["MSG_TUR_PROBE_REAR_G3"], "node", ["probe_kestrel_turret1"]]]], + "armor", [66], + "health", [66], + "dare_devil", [5], + "natural_touch", [4], + "sixth_sense", [7], + "dead_eye", [5], + "quick_draw", [6], + "steady_hand", [6], + "stun_recovery", [5], + "talker", [6], + "constitution", [6], + "weapons", + [ + ["wep_probe_gun", 440, 0.5, 0, 860], + ["wep_probe_rocket", 4, 4, 175, 1500] + ], + "collision", [[0.0, 0.0, 0.0]] + ] + ] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/voice.json b/CSVM.Tests/fixtures/synthetic/zrdr/voice.json new file mode 100644 index 000000000..329af8062 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/voice.json @@ -0,0 +1,5 @@ +[ + [0.0, 2.0, 26.0], + [1.0, 2.0], + [2.0, 26.0] +] diff --git a/CSVM.Tests/fixtures/synthetic/zrdr/weapons.json b/CSVM.Tests/fixtures/synthetic/zrdr/weapons.json new file mode 100644 index 000000000..fb0a1e355 --- /dev/null +++ b/CSVM.Tests/fixtures/synthetic/zrdr/weapons.json @@ -0,0 +1,437 @@ +[ + [ + "VERSION", [2], + "NO_AMMO_WARNING", ["snd_probe_emptyclip"], + "BALLISTICS", + [ + "wep_probe_gun", + [ + "DESC", ["MSG_PROBE_GUN"], + "NAME", ["probegun"], + "CALIBER", [30], + "CLUSTER_SIZE", [600], + "AMMO_LIMIT", [1200], + "FIRE_RATE", [9], + "VELOCITY", [700], + "RANGE", [1000], + "GRAVITY", [0], + "CANNON_SPREAD", [4], + "ARMOR_DAMAGE", [2], + "HEALTH_DAMAGE", [2], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_probe_rocket", + [ + "DESC", ["MSG_PROBE_ROCKET"], + "NAME", ["proberocket"], + "PRIORITY", [5], + "CLUSTER_SIZE", [2], + "AMMO_LIMIT", [8], + "ROCKET", null, + "HIGH_EXPLOSIVE", null, + "FIRE_RATE", [1], + "VELOCITY", [160], + "ACCELERATION", [80], + "RANGE", [2500], + "TURN_RATE", [0.001], + "LOCK_ON", [1.5], + "ARMOR_DAMAGE", [40], + "HEALTH_DAMAGE", [40], + "IMPACT_PROXIMITY", [20], + "DETONATION_DISTANCE", [8], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_torpedo", + [ + "DESC", ["MSG_PROBE_TORPEDO"], + "NAME", ["probetorp"], + "PRIORITY", [4], + "CLUSTER_SIZE", [1], + "ROCKET", null, + "TORPEDO", null, + "TARGETABLE", null, + "FLYOUT_HEALTH", [12], + "PROJECTILE_BBOX", [0], + "DESTROY_ANIMATION", ["probe_torpedo_shotdown"], + "FLYOUT", ["MODEL", ["probe_torpbody"], "MODEL_ANIMATION", ["probe_torpedo_trail"]], + "FIRE_RATE", [1], + "VELOCITY", [50], + "RANGE", [1100], + "RANGE_MINIMUM", [240, 0], + "TURN_RATE", [0.002], + "LOCK_ON", [2], + "ARMOR_DAMAGE", [120], + "HEALTH_DAMAGE", [150], + "IMPACT_PROXIMITY", [36], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["torpedo_ground_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_he", + [ + "DESC", ["MSG_PROBE_HE"], + "NAME", ["probehe"], + "PRIORITY", [5], + "CLUSTER_SIZE", [3], + "ROCKET", null, + "HIGH_EXPLOSIVE", null, + "FIRE_RATE", [1], + "VELOCITY", [900], + "RANGE", [1400], + "LOCK_ON", [1.2], + "ARMOR_DAMAGE", [50], + "HEALTH_DAMAGE", [70], + "IMPACT_PROXIMITY", [18], + "DETONATION_DISTANCE", [6], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["SURFACE_ANIMATION", ["he_ground_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_choker", + [ + "DESC", ["MSG_PROBE_CHOKER"], + "NAME", ["probechoke"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "TANGLER", ["TIME", [1.6], "RADIUS", [30], "ENGINE_DEAD", [4, 11]], + "FIRE_RATE", [1], + "VELOCITY", [1100], + "RANGE", [1500], + "ARMOR_DAMAGE", [0], + "HEALTH_DAMAGE", [0], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["scatter_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_flare", + [ + "DESC", ["MSG_PROBE_FLARE"], + "NAME", ["probeflare"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "FIRE_RATE", [1], + "VELOCITY", [1.2], + "DETONATION_TIME", [1.5], + "DAMAGE", [0], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["rear_flash_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_sonic", + [ + "DESC", ["MSG_PROBE_SONIC"], + "NAME", ["probesonic"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "SONIC", null, + "FIRE_RATE", [1], + "VELOCITY", [650], + "RANGE", [1050], + "LOCK_ON", [1.4], + "ARMOR_DAMAGE", [0], + "HEALTH_DAMAGE", [0], + "IMPACT_PROXIMITY", [34], + "DETONATION_DISTANCE", [34], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["sonic_ground_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_flash", + [ + "DESC", ["MSG_PROBE_FLASH"], + "NAME", ["probeflash"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "FLASH", null, + "FIRE_RATE", [1], + "VELOCITY", [700], + "RANGE", [1000], + "LOCK_ON", [1.4], + "DAMAGE", [0], + "IMPACT_PROXIMITY", [380], + "DETONATION_DISTANCE", [20], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["flash_effect"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_seeker", + [ + "DESC", ["MSG_PROBE_SEEKER"], + "NAME", ["probeseek"], + "PRIORITY", [5], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "BEEPER_SEEKER", null, + "FIRE_RATE", [1], + "VELOCITY", [300], + "RANGE", [6000], + "LOCK_ON", [1.5], + "TURN_RATE", [1.1], + "ARMOR_DAMAGE", [30], + "HEALTH_DAMAGE", [45], + "IMPACT_PROXIMITY", [15], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", [ + "default", ["ANIMATION", ["ballflare.flt"], "SOUND", ["snd_probe_boom"]], + "player", ["ANIMATION", ["large_fireball"], "SOUND", ["snd_probe_boom"]] + ] + ], + "wep_probe_beeper", + [ + "DESC", ["MSG_PROBE_BEEPER"], + "NAME", ["probebeep"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "BEEPER", ["TIME", [14]], + "FIRE_RATE", [1], + "VELOCITY", [960], + "RANGE", [1200], + "LOCK_ON", [1.2], + "ARMOR_DAMAGE", [0], + "HEALTH_DAMAGE", [0], + "DETONATION_DISTANCE", [14], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", [ + "default", null, + "player", ["ANIMATION", ["large_fireball"], "SOUND", ["snd_probe_boom"]] + ] + ], + "wep_probe_flak", + [ + "DESC", ["MSG_PROBE_FLAK"], + "NAME", ["probeflak"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "FIRE_RATE", [1], + "VELOCITY", [800], + "RANGE", [1200], + "LOCK_ON", [1.2], + "ARMOR_DAMAGE", [20], + "HEALTH_DAMAGE", [25], + "IMPACT_PROXIMITY", [45], + "DETONATION_DISTANCE", [40], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", [ + "default", ["ANIMATION", ["flak_effect"], "SOUND", ["snd_probe_boom"]], + "player", ["ANIMATION", ["probe_flak_player"]] + ] + ], + "wep_probe_incendiary", + [ + "DESC", ["MSG_PROBE_INCENDIARY"], + "NAME", ["probeinc"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "AMMO_LIMIT", [100], + "ROCKET", null, + "FIRE_RATE", [1], + "VELOCITY", [280], + "ACCELERATION", [100], + "RANGE", [620], + "TURN_RATE", [0.001], + "LOCK_ON_LEAD", [5, 9], + "ARMOR_DAMAGE", [15], + "HEALTH_DAMAGE", [30], + "IMPACT_PROXIMITY", [12], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["small_fireball"], "SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_smoker", + [ + "DESC", ["MSG_PROBE_SMOKER"], + "NAME", ["probesmoke"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "ROCKET", null, + "REAR", null, + "SMOKE_SCREEN", ["TIME", [6.5]], + "FIRE_RATE", [1], + "VELOCITY", [1], + "DAMAGE", [0], + "FIRE", ["SOUND", ["snd_probe_launch"]] + ], + "wep_probe_blastrocket", + [ + "DESC", ["MSG_PROBE_BLAST_ROCKET"], + "NAME", ["probeblast"], + "PRIORITY", [4], + "CLUSTER_SIZE", [2], + "AMMO_LIMIT", [6], + "ROCKET", null, + "HIGH_EXPLOSIVE", null, + "FIRE_RATE", [1], + "VELOCITY", [620], + "RANGE", [2200], + "ARMOR_DAMAGE", [30], + "HEALTH_DAMAGE", [30], + "IMPACT_PROXIMITY", [66], + "DETONATION_DISTANCE", [11], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_gunhit", + [ + "DESC", ["MSG_PROBE_GUNHIT"], + "NAME", ["probestrike"], + "CALIBER", [30], + "CLUSTER_SIZE", [300], + "AMMO_LIMIT", [600], + "FIRE_RATE", [6], + "VELOCITY", [650], + "RANGE", [900], + "GRAVITY", [0], + "CANNON_SPREAD", [2], + "ARMOR_DAMAGE", [3], + "HEALTH_DAMAGE", [3], + "CANNON", null, + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", [ + "default", ["ANIMATION", ["3040slug_gunhit"], "SOUND", ["snd_probe_hit"]], + "player", ["ANIMATION", ["3040slug_gunhit"], "SOUND", ["snd_probe_hit"]] + ] + ], + "wep_30", + [ + "DESC", ["MSG_PROBE_CAL30"], + "NAME", ["probecal30"], + "CALIBER", [30], + "CLUSTER_SIZE", [500], + "AMMO_LIMIT", [1000], + "FIRE_RATE", [10], + "VELOCITY", [720], + "RANGE", [950], + "GRAVITY", [0], + "CANNON_SPREAD", [4], + "ARMOR_DAMAGE", [1.5], + "HEALTH_DAMAGE", [1.5], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_40", + [ + "DESC", ["MSG_PROBE_CAL40"], + "NAME", ["probecal40"], + "CALIBER", [40], + "CLUSTER_SIZE", [400], + "AMMO_LIMIT", [800], + "FIRE_RATE", [8], + "VELOCITY", [690], + "RANGE", [1000], + "GRAVITY", [0], + "CANNON_SPREAD", [4], + "ARMOR_DAMAGE", [2.5], + "HEALTH_DAMAGE", [2.5], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_50", + [ + "DESC", ["MSG_PROBE_CAL50"], + "NAME", ["probecal50"], + "CALIBER", [50], + "CLUSTER_SIZE", [300], + "AMMO_LIMIT", [600], + "FIRE_RATE", [7], + "VELOCITY", [660], + "RANGE", [1050], + "GRAVITY", [0], + "CANNON_SPREAD", [5], + "ARMOR_DAMAGE", [3.5], + "HEALTH_DAMAGE", [3.5], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_60", + [ + "DESC", ["MSG_PROBE_CAL60"], + "NAME", ["probecal60"], + "CALIBER", [60], + "CLUSTER_SIZE", [240], + "AMMO_LIMIT", [480], + "FIRE_RATE", [6], + "VELOCITY", [630], + "RANGE", [1100], + "GRAVITY", [0], + "CANNON_SPREAD", [5], + "ARMOR_DAMAGE", [4.5], + "HEALTH_DAMAGE", [4.5], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_70", + [ + "DESC", ["MSG_PROBE_CAL70"], + "NAME", ["probecal70"], + "CALIBER", [70], + "CLUSTER_SIZE", [180], + "AMMO_LIMIT", [360], + "FIRE_RATE", [5], + "VELOCITY", [600], + "RANGE", [1150], + "GRAVITY", [0], + "CANNON_SPREAD", [6], + "ARMOR_DAMAGE", [6], + "HEALTH_DAMAGE", [6], + "CANNON", null, + "LOOPED_SOUND_NAME", ["snd_probe_gunloop"], + "FIRE", ["SOUND", ["snd_probe_fire"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_hit"]]] + ], + "wep_06", + [ + "DESC", ["MSG_PROBE_PYLON_HE"], + "NAME", ["probepylonhe"], + "PRIORITY", [5], + "CLUSTER_SIZE", [2], + "AMMO_LIMIT", [4], + "ROCKET", null, + "HIGH_EXPLOSIVE", null, + "FIRE_RATE", [1], + "VELOCITY", [180], + "ACCELERATION", [70], + "RANGE", [2200], + "TURN_RATE", [0.001], + "LOCK_ON", [1.4], + "ARMOR_DAMAGE", [35], + "HEALTH_DAMAGE", [45], + "IMPACT_PROXIMITY", [18], + "DETONATION_DISTANCE", [7], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["SOUND", ["snd_probe_boom"]]] + ], + "wep_probe_zeptorp", + [ + "DESC", ["MSG_PROBE_ZEPTORP"], + "NAME", ["probezeptorp"], + "PRIORITY", [4], + "CLUSTER_SIZE", [1], + "ROCKET", null, + "TORPEDO", null, + "DAMAGES_ZEPPELIN", null, + "FIRE_RATE", [1], + "VELOCITY", [70], + "RANGE", [1300], + "RANGE_MINIMUM", [260, 0], + "TURN_RATE", [0.002], + "LOCK_ON", [1.5], + "ARMOR_DAMAGE", [140], + "HEALTH_DAMAGE", [170], + "IMPACT_PROXIMITY", [30], + "FIRE", ["SOUND", ["snd_probe_launch"]], + "IMPACT", ["default", ["ANIMATION", ["torpedo_ground_effect"], "SOUND", ["snd_probe_boom"]]] + ] + ] + ] +] diff --git a/CSVM/data/ocean_seas.json b/CSVM/data/ocean_seas.json new file mode 100644 index 000000000..7c3a68aa1 --- /dev/null +++ b/CSVM/data/ocean_seas.json @@ -0,0 +1,50 @@ +{ + "_about": "The Enhanced wave ocean's sea per chapter (src/Effects/OceanSeas.cs). Each entry holds only the fields that differ from the defaults, which are the ocean's own tune; an empty entry or a missing field takes the default. The field names, ranges and meanings are SeaState.Fields (src/Effects/SeaState.cs). The ocean lab (Shift+F1 in --freecam) writes one chapter's entry here when run from the source tree; --debug-ocean=:,... overrides it for one launch.", + "C1": { + "wind": 295, + "look_from": 55, + "look_full": 143 + }, + "C1B": { + "height": 2, + "length": 1.27, + "sharpness": 0.31, + "bend_m": 61, + "detail_slope": 0.125 + }, + "C1C": { + "height": 2, + "length": 0.64, + "sharpness": 0.46, + "foam_threshold": 2.55, + "foam_cover": 0.1 + }, + "C2": { + "height": 0.39, + "length": 1.71, + "wind": 291, + "look_from": 10, + "look_full": 127 + }, + "C2B": { + "height": 1.8518, + "length": 1.88, + "sharpness": 0.54, + "bend_m": 39, + "bend_rad": 7, + "foam_threshold": 3, + "foam_patch": 1.1, + "foam_cover": 0.29 + }, + "C3": { + "height": 0.99, + "length": 1.52, + "wind": 249, + "bend_m": 51, + "swell_from": 6, + "swell_full": 125, + "look_from": 8, + "look_full": 113 + }, + "C5": {} +} diff --git a/CSVM/shaders/csky_ocean.gdshaderinc b/CSVM/shaders/csky_ocean.gdshaderinc new file mode 100644 index 000000000..6ddca8e0d --- /dev/null +++ b/CSVM/shaders/csky_ocean.gdshaderinc @@ -0,0 +1,63 @@ +#ifndef CSKY_OCEAN_INC +#define CSKY_OCEAN_INC + +// The Enhanced wave ocean's switch, shared by the ocean and the base sea sheet it replaces. +// 1 while an ocean node is live (Effects/Ocean.cs), 0 otherwise. +global uniform float csky_ocean_on; + +// The live ocean's shore mask (sea coverage in R), its zone groups (R8) and the mask's world rect +// (origin X/Z, then 1 / extent), published by Effects/OceanMask.cs. With no ocean live they hold +// 1x1 defaults: no sea, and a zone every grid draws. +global uniform sampler2D csky_ocean_mask; +global uniform sampler2D csky_ocean_zone; +global uniform vec4 csky_ocean_rect; + +// The mask's sea coverage at a world X/Z, filtered as the ocean's own linear, clamp-to-edge sampler +// reads it. Fetched texel by texel, so no sampler state a global uniform carries can change it. +float csky_ocean_sea(vec2 xz) { + ivec2 size = textureSize(csky_ocean_mask, 0); + vec2 t = (xz - csky_ocean_rect.xy) * csky_ocean_rect.zw * vec2(size) - 0.5; + ivec2 i = ivec2(floor(t)); + vec2 f = t - floor(t); + ivec2 hi = size - 1; + float a = texelFetch(csky_ocean_mask, clamp(i, ivec2(0), hi), 0).r; + float b = texelFetch(csky_ocean_mask, clamp(i + ivec2(1, 0), ivec2(0), hi), 0).r; + float c = texelFetch(csky_ocean_mask, clamp(i + ivec2(0, 1), ivec2(0), hi), 0).r; + float d = texelFetch(csky_ocean_mask, clamp(i + ivec2(1, 1), ivec2(0), hi), 0).r; + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// Whether any texel the filter reads at a world X/Z is a ramp's open sea (OceanMaskRaster.RampSea). +bool csky_ocean_ramp(vec2 xz) { + ivec2 size = textureSize(csky_ocean_mask, 0); + ivec2 i = ivec2(floor((xz - csky_ocean_rect.xy) * csky_ocean_rect.zw * vec2(size) - 0.5)); + ivec2 hi = size - 1; + vec4 r = vec4(texelFetch(csky_ocean_mask, clamp(i, ivec2(0), hi), 0).r, + texelFetch(csky_ocean_mask, clamp(i + ivec2(1, 0), ivec2(0), hi), 0).r, + texelFetch(csky_ocean_mask, clamp(i + ivec2(0, 1), ivec2(0), hi), 0).r, + texelFetch(csky_ocean_mask, clamp(i + ivec2(1, 1), ivec2(0), hi), 0).r); + return any(lessThan(abs(r * 255.0 - 254.0), vec4(0.5))); +} + +// Whether a base-sea fragment at world position `world` steps aside for the ocean: only where the +// ocean itself draws, so each texel has one owner and a sheet shown after the mask bake (C3's +// drained crater floor) draws flat instead of leaving a hole. The coverage threshold is the +// ocean's own discard (OceanMask.SeaThreshold). A zone-seam texel (OceanMaskRaster.AnyZone - 1) is +// one no grid draws. A ramp clears up to OceanMask.RampTop; C3's raised water and C4's lakes stay. +bool csky_ocean_hides_sea(vec3 world) { + if (csky_ocean_on < 0.5 || world.y <= -0.25 || world.y >= 4.0) { + return false; + } + if (world.y >= 0.25 && !csky_ocean_ramp(world.xz)) { + return false; + } + if (csky_ocean_sea(world.xz) < 0.02) { + return false; + } + ivec2 size = textureSize(csky_ocean_zone, 0); + ivec2 i = ivec2(floor((world.xz - csky_ocean_rect.xy) * csky_ocean_rect.zw * vec2(size))); + float zone = texelFetch(csky_ocean_zone, clamp(i, ivec2(0), size - 1), 0).r * 255.0; + return abs(zone - 254.0) > 0.5; +} + +#endif diff --git a/CSVM/shaders/csky_ocean.gdshaderinc.uid b/CSVM/shaders/csky_ocean.gdshaderinc.uid new file mode 100644 index 000000000..aeca3af62 --- /dev/null +++ b/CSVM/shaders/csky_ocean.gdshaderinc.uid @@ -0,0 +1 @@ +uid://b0p4347dj763w diff --git a/CSVM/src/Effects/Ocean.cs b/CSVM/src/Effects/Ocean.cs new file mode 100644 index 000000000..1273c87ee --- /dev/null +++ b/CSVM/src/Effects/Ocean.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using CSVM.Mech3; +using CSVM.Utils; +using Godot; + +namespace CSVM.Effects; + +/// +/// The Enhanced-only wave ocean that replaces the flat sea-level sheet. A camera-centred polar grid +/// carries a Gerstner wave sum in its vertex stage, and finer waves live in its normals. A mask +/// baked from the world's water polygons () calms the waves under the surf +/// ring, along every shore and around the ships. The base sheet's colliders stay flat at y = 0; only +/// its triangles step aside (csky_ocean.gdshaderinc). Driven by csky_time, so a network peer +/// and a fixed-step capture see the same waves. The chapter's writes the +/// shader text () and its uniforms; takes a new one live. +/// +public sealed partial class Ocean : Node3D +{ + /// The global switch's name, declared in res://shaders/csky_ocean.gdshaderinc. + public const string Param = "csky_ocean_on"; + + // The grid's angular resolution near the eye; it halves at each of BandEnds. + private const int InnerSegments = 384; + + // TUNE. The waves stay flat this far past a ship's zone, more than the swell's sideways sway, + // and reach full height over the fade beyond. Flat water keeps a wake sheet on the surface. + private const float CalmMargin = 6f; + private const float CalmFade = 50f; + private const int MaxShips = OceanShader.MaxShips; + + // The hull meshes that reach this close to the hull's own y = 0 make its waterline. + private const float WaterlineBand = OceanMovers.WaterlineBand; + + private static readonly StringName ParamName = Param; + private static readonly float[] BandEnds = { 700f, 2800f, 11000f, 45000f }; + + private readonly Func> _ships; + private readonly List _wakes; + private readonly Node3D _worldRoot; + private readonly ShaderMaterial _material; + private readonly Vector4[] _calmAxes = new Vector4[MaxShips]; + private readonly Vector4[] _calmSizes = new Vector4[MaxShips]; + private readonly OceanCalmZone[] _zones = new OceanCalmZone[MaxShips]; + private readonly Node3D?[] _zoneHulls = new Node3D?[MaxShips]; + private readonly Dictionary _waterlines = new(); + private readonly OceanMask _mask; + private readonly List _candidates = new(); + private readonly List _candidateAt = new(); + private readonly List _candidateWake = new(); + private readonly List _order = new(); + private readonly Dictionary _nestedRest = new(); + private readonly HashSet _zoned = new(); + private readonly int _level; + private readonly bool _zonedSheet; + + private Ocean(ShaderMaterial material, Func> ships, OceanMask mask, Node3D worldRoot, + int level, SeaState sea) + { + _mask = mask; + _wakes = mask.Wakes; + Name = "Ocean"; + _material = material; + _ships = ships; + _worldRoot = worldRoot; + _level = level; + _zonedSheet = mask.ZoneTexture != null; + Sea = sea; + } + + /// The sea the ocean draws, clamped. + public SeaState Sea { get; private set; } + + /// The grids' shader text, as the sea last wrote it. + public string ShaderText => _material.Shader.Code; + + /// Declares the switch and the mask globals the base sheet's hide reads. Must run before + /// the first shader that reads them is built. + public static void RegisterGlobal() + { + RenderingServer.GlobalShaderParameterAdd(Param, RenderingServer.GlobalShaderParameterType.Float, 0.0f); + OceanMask.RegisterGlobals(); + } + + /// Whether a chapter's sea gets the ocean: every chapter with a sea at y = 0 + /// (). + public static bool Covers(string chapter) => OceanSeas.IsSeaChapter(chapter); + + /// Builds the ocean over the world under with the chapter's + /// , or null when that world has no sea-level base sheet to replace. The + /// an animation carries () are calmed around as + /// hulls wherever they float. A non-empty receives the baked mask. + public static Ocean? Create(Node3D worldRoot, SceneBuilder scene, TextureArchive textures, + Func> ships, ISet skip, IReadOnlyCollection movers, SeaState sea, + string maskPng) + { + long start = System.Diagnostics.Stopwatch.GetTimestamp(); + var mask = OceanMask.Bake(worldRoot, scene, skip, movers); + if (mask == null) + { + Log.Info("world", $"ocean: no sea-level base sheet, not built"); + return null; + } + double bakeMs = System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds; + var baseTex = textures.Find(mask.BaseTexture); + + bool zoned = mask.ZoneTexture != null; + sea = sea.Clamped(); + int level = mask.BaseLevel - 1; + var shader = new Shader { Code = OceanShader.Code(sea, level, zoned) }; + var material = new ShaderMaterial { Shader = shader }; + material.SetShaderParameter("mask_tex", mask.Texture); + material.SetShaderParameter("mask_rect", new Vector4(mask.Origin.X, mask.Origin.Y, 1f / mask.Size.X, 1f / mask.Size.Y)); + if (baseTex != null) + material.SetShaderParameter("albedo_tex", baseTex); + material.SetShaderParameter("tile_m", mask.TileMetres); + material.SetShaderParameter("tint_tex", mask.TintTexture); + material.SetShaderParameter("lift_tex", mask.LiftTexture); + if (mask.ZoneTexture is { } zoneTex) + material.SetShaderParameter("zone_tex", zoneTex); + + var ocean = new Ocean(material, ships, mask, worldRoot, level, sea); + ocean.SetSeaUniforms(); + ocean.PublishShips(); + var mesh = BuildGrid(); + mesh.SurfaceSetMaterial(0, material); + // One grid per zone group, on that group's layers. The zone gate then culls the waves with + // the sheet they replace; C5 splits its sea over zone_id 1 and 3. + for (int z = 0; z < Math.Max(1, mask.ZoneLayers.Count); z++) + { + var instance = new MeshInstance3D + { + Name = z == 0 ? "OceanGrid" : $"OceanGrid{z}", + Mesh = mesh, + CastShadow = GeometryInstance3D.ShadowCastingSetting.Off, + // The grid follows every camera in its vertex stage, so it must never be culled. + CustomAabb = new Aabb(new Vector3(-80000f, -50f, -80000f), new Vector3(160000f, 100f, 160000f)), + }; + if (z < mask.ZoneLayers.Count) + instance.Layers = mask.ZoneLayers[z]; + if (zoned) + instance.SetInstanceShaderParameter("zone_index", (float)z); + ocean.AddChild(instance); + } + double ms = System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds; + Log.Info("world", $"ocean: built grid verts={mesh.SurfaceGetArrayLen(0)} mask={mask.Width}x{mask.Height} cell={OceanMask.Cell}m origin=({mask.Origin.X:0},{mask.Origin.Y:0}) base={mask.BaseTexture} tile={mask.TileMetres:0.0}m base_tris={mask.BaseTriangles} base_level={mask.BaseLevel} zone_groups={mask.ZoneLayers.Count} edge_tris={mask.EdgeTriangles} ramp_tris={mask.RampTriangles} solid_tris={mask.SolidTriangles} wakes={mask.Wakes.Count} movers={movers.Count} moving_hulls={mask.MovingHulls.Count} unjudged={mask.UnjudgedMovers.Count} hull_tris={mask.MovingHullTriangles} ms={ms:0} bake={bakeMs:0} ({mask.Timing}) sea={sea.Describe()}"); + foreach (var hull in mask.MovingHulls) + { + var p = hull.GlobalPosition; + var line = ocean.Waterline(hull); + string size = line is { } l ? string.Create(CultureInfo.InvariantCulture, $"{l.Size.X:0}x{l.Size.Z:0}m") : "none"; + Log.Info("world", $"ocean: moving hull '{hull.Name}' at ({p.X:0},{p.Y:0.0},{p.Z:0}) waterline {size}"); + } + foreach (var mover in mask.UnjudgedMovers) + Log.Debug("world", $"ocean: mover '{mover.Name}' hidden at the bake, judged when shown"); + foreach (var wake in mask.Wakes) + { + if (wake.IsInsideTree() && wake is MeshInstance3D { Mesh: { } wakeMesh }) + { + var p = wake.GlobalPosition; + var hull = ocean.HullOf(wake); + var zone = new OceanCalmZone(new Vector2(p.X, p.Z), Heading(hull)); + zone.Add(wakeMesh.GetAabb(), wake.GlobalTransform); + var half = zone.HalfExtents; + Log.Info("world", $"ocean: wake '{wake.GetParent()?.Name}/{wake.Name}' of '{hull.Name}' at ({p.X:0},{p.Y:0.0},{p.Z:0}) sheet {2f * half.X:0}x{2f * half.Y:0}m visible={wake.IsVisibleInTree()}"); + } + } + if (maskPng.Length > 0) + { + var err = mask.Image.SavePng(maskPng); + if (err == Error.Ok) + err = mask.TintImage.SavePng(System.IO.Path.ChangeExtension(maskPng, ".tint.png")); + if (err == Error.Ok) + Log.Info("world", $"ocean: mask written to {maskPng}"); + else + Log.Error("world", $"--dump-ocean-mask: could not write {maskPng} ({err})"); + } + return ocean; + } + + // The sheet hides only where this ocean's mask says it draws, so the mask goes live with it. + public override void _EnterTree() + { + _mask.Publish(); + RenderingServer.GlobalShaderParameterSet(ParamName, 1.0f); + } + + public override void _ExitTree() + { + RenderingServer.GlobalShaderParameterSet(ParamName, 0.0f); + OceanMask.Withdraw(); + } + + public override void _Process(double delta) => PublishShips(); + + /// Takes live, clamped. The shader recompiles only when its text + /// changes; height, foam strength and roughness are uniforms and change nothing else. The grids, + /// the mask and the flat colliders stay as they are. + public void Apply(SeaState sea) + { + Sea = sea.Clamped(); + string code = OceanShader.Code(Sea, _level, _zonedSheet); + if (!string.Equals(code, _material.Shader.Code, StringComparison.Ordinal)) + _material.Shader.Code = code; + SetSeaUniforms(); + } + + // Concentric rings around the origin whose spacing grows with radius, so a triangle covers + // about the same screen area at every distance. UV.x carries the local spacing for the + // vertex stage's per-wave fade. + private static ArrayMesh BuildGrid() + { + var verts = new List(); + var uvs = new List(); + var idx = new List(); + verts.Add(Vector3.Zero); + uvs.Add(new Vector2(0.5f, 0f)); + int n = InnerSegments; + float r = 4f; + int prevStart = AddRing(verts, uvs, r, n); + int prevN = n; + for (int i = 0; i < n; i++) + idx.AddRange(new[] { 0, prevStart + i, prevStart + ((i + 1) % n) }); + for (int band = 0; band < BandEnds.Length; band++) + { + while (r < BandEnds[band]) + { + r *= 1f + (Mathf.Tau / n); + int start = AddRing(verts, uvs, r, n); + Stitch(idx, prevStart, prevN, start, n); + prevStart = start; + prevN = n; + } + n = Math.Max(n / 2, 24); + } + var arrays = new Godot.Collections.Array(); + arrays.Resize((int)Mesh.ArrayType.Max); + arrays[(int)Mesh.ArrayType.Vertex] = verts.ToArray(); + arrays[(int)Mesh.ArrayType.TexUV] = uvs.ToArray(); + arrays[(int)Mesh.ArrayType.Index] = idx.ToArray(); + var mesh = new ArrayMesh(); + mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, arrays); + return mesh; + } + + private static int AddRing(List verts, List uvs, float r, int n) + { + int start = verts.Count; + float spacing = r * Mathf.Tau / n; + for (int i = 0; i < n; i++) + { + float a = Mathf.Tau * i / n; + verts.Add(new Vector3(r * Mathf.Cos(a), 0f, r * Mathf.Sin(a))); + uvs.Add(new Vector2(spacing, 0f)); + } + return start; + } + + // Joins an inner ring to the next one out, which has the same count or half of it. + private static void Stitch(List idx, int a, int na, int b, int nb) + { + if (na == nb) + { + for (int i = 0; i < na; i++) + { + int i1 = (i + 1) % na; + idx.AddRange(new[] { a + i, b + i, b + i1 }); + idx.AddRange(new[] { a + i, b + i1, a + i1 }); + } + return; + } + for (int k = 0; k < nb; k++) + { + int k1 = (k + 1) % nb; + int i0 = 2 * k, i1 = 2 * k + 1, i2 = (2 * k + 2) % na; + idx.AddRange(new[] { a + i0, b + k, a + i1 }); + idx.AddRange(new[] { a + i1, b + k, b + k1 }); + idx.AddRange(new[] { a + i1, b + k1, a + i2 }); + } + } + + // A ship's horizontal heading, its local +Z on the water. + private static Vector2 Heading(Node3D hull) + { + var z = hull.GlobalBasis.Z; + return new Vector2(z.X, z.Z); + } + + // Whether a hull or wake sheet shows with its origin on sea level; a hoisted lifeboat's is not. + private static bool Afloat(Node3D node) => + IsInstanceValid(node) && node.IsInsideTree() && node.IsVisibleInTree() + && Mathf.Abs(node.GlobalPosition.Y) <= OceanMovers.OriginBand; + + // A visible wake sheet's mesh while the sheet lies on the water, else null. + private static Mesh? WakeOnWater(Node3D wake) => + Afloat(wake) && wake is MeshInstance3D { Mesh: { } mesh } ? mesh : null; + + // The sea's uniforms. At the defaults they equal the values the shader text declares. + private void SetSeaUniforms() + { + _material.SetShaderParameter("wave_scale", Sea.Height); + _material.SetShaderParameter("foam_strength", Sea.FoamStrength); + _material.SetShaderParameter("rough_near", Sea.RoughNear); + _material.SetShaderParameter("rough_far", Sea.RoughFar); + } + + // The hull a wake sheet trails: the sheet's mesh sits under its own node, which hangs off the + // ship. A wake node straight under the world root is its own hull. + private Node3D HullOf(Node3D wake) + { + var node = wake.GetParent() as Node3D ?? wake; + return node.GetParent() is Node3D ship && ship != _worldRoot ? ship : node; + } + + // One zone per ship on the water, over its waterline and its visible wake sheets, each frame; + // an unused slot sits far away. With more ships afloat than slots, the nearest to the eye win. + private void PublishShips() + { + _candidates.Clear(); + _candidateAt.Clear(); + _candidateWake.Clear(); + foreach (var hull in _ships()) + Offer(hull, wake: false); + foreach (var hull in _mask.MovingHulls) + Offer(hull, wake: false); + foreach (var mover in _mask.UnjudgedMovers) + { + if (Afloat(mover) && Waterline(mover) != null && _mask.IsSea(mover.GlobalPosition)) + Offer(mover, wake: false); + } + for (int i = _candidates.Count - 1; i >= 0; i--) + { + if (Rides(_candidates[i])) + { + _candidates.RemoveAt(i); + _candidateAt.RemoveAt(i); + _candidateWake.RemoveAt(i); + } + } + foreach (var wake in _wakes) + { + if (WakeOnWater(wake) is { } && ZoneHullOf(wake, _candidates, _candidates.Count) < 0) + Offer(HullOf(wake), wake: true); + } + int n = 0; + var eye = IsInsideTree() ? GetViewport()?.GetCamera3D()?.GlobalPosition : null; + // Without a camera the first listed keep the slots: roster, moving, then wake hulls. + OceanMovers.Nearest(_candidateAt, eye ?? Vector3.Zero, eye != null ? MaxShips : int.MaxValue, _order); + if (_order.Count > MaxShips) + _order.RemoveRange(MaxShips, _order.Count - MaxShips); + foreach (int i in _order) + { + var hull = _candidates[i]; + int added = AddHull(hull, n); + if (added == n && _candidateWake[i]) + { + // A wake's ship keeps its zone wherever its own origin stands. + var o = hull.GlobalPosition; + _zones[n] = new OceanCalmZone(new Vector2(o.X, o.Z), Heading(hull)); + _zoneHulls[n] = hull; + added = n + 1; + } + if (added > n && _zoned.Add(hull)) + Log.Debug("world", $"ocean: zone over '{hull.Name}' at ({_candidateAt[i].X:0},{_candidateAt[i].Y:0.0},{_candidateAt[i].Z:0}) of {_candidates.Count} afloat"); + n = added; + } + foreach (var wake in _wakes) + { + if (WakeOnWater(wake) is { } mesh && ZoneHullOf(wake, _zoneHulls, n) is var z and >= 0) + _zones[z].Add(mesh.GetAabb(), wake.GlobalTransform); + } + for (int i = 0; i < MaxShips; i++) + { + if (i < n) + { + var c = _zones[i].Center; + var axis = _zones[i].Axis; + var half = _zones[i].HalfExtents; + _calmAxes[i] = new Vector4(c.X, c.Y, axis.X, axis.Y); + _calmSizes[i] = new Vector4(half.X, half.Y, CalmMargin, CalmFade); + } + else + { + _calmAxes[i] = new Vector4(1e9f, 1e9f, 1f, 0f); + _calmSizes[i] = new Vector4(0f, 0f, 0f, 1f); + } + _zoneHulls[i] = null; + } + _material.SetShaderParameter("calm_axes", _calmAxes); + _material.SetShaderParameter("calm_sizes", _calmSizes); + } + + // Opens a zone over a visible hull on the water and its waterline; returns the zones in use. + private int AddHull(Node3D hull, int n) + { + if (n >= MaxShips || !Afloat(hull) || Array.IndexOf(_zoneHulls, hull, 0, n) >= 0) + return n; + var xf = hull.GlobalTransform; + _zones[n] = new OceanCalmZone(new Vector2(xf.Origin.X, xf.Origin.Z), Heading(hull)); + if (Waterline(hull) is { } line) + _zones[n].Add(line, xf); + else + _zones[n].Add(new Vector2(xf.Origin.X, xf.Origin.Z)); + _zoneHulls[n] = hull; + return n + 1; + } + + // The union of the hull's meshes that reach its waterline, in the hull's own frame, measured + // once. The wake sheets are left out; they move on their own and are added each frame. + private Aabb? Waterline(Node3D hull) + { + if (_waterlines.TryGetValue(hull, out var kept)) + return kept; + Aabb? line = null; + var toHull = hull.GlobalTransform.AffineInverse(); + var stack = new Stack(); + stack.Push(hull); + while (stack.Count > 0) + { + var node = stack.Pop(); + foreach (var child in node.GetChildren()) + stack.Push(child); + if (node is not MeshInstance3D { Mesh: { } mesh } mi || !mi.IsVisibleInTree() || _wakes.Contains(mi)) + continue; + var box = toHull * mi.GlobalTransform * mesh.GetAabb(); + if (box.Position.Y > WaterlineBand || box.End.Y < -WaterlineBand) + continue; + line = line is { } l ? l.Merge(box) : box; + } + _waterlines[hull] = line; + return line; + } + + // Lists a ship for a zone this frame, once. A wake's ship needs no origin on the water. + private void Offer(Node3D hull, bool wake) + { + if (!IsInstanceValid(hull) || !hull.IsInsideTree() || (!wake && !Afloat(hull)) || _candidates.Contains(hull)) + return; + _candidates.Add(hull); + _candidateAt.Add(hull.GlobalPosition); + _candidateWake.Add(wake); + } + + // The first of hulls[0..count) that is the wake's ship or holds the sheet, else -1. + private int ZoneHullOf(Node3D wake, IReadOnlyList hulls, int count) + { + var ship = HullOf(wake); + for (int i = 0; i < count; i++) + { + if (hulls[i] is { } h && (h == ship || h.IsAncestorOf(wake))) + return i; + } + return -1; + } + + // Whether a hull rides inside another listed hull where it stood when first seen there, which + // that hull's waterline already covers. A part that moves off on its own keeps a zone. + private bool Rides(Node3D inner) + { + foreach (var outer in _candidates) + { + if (outer == inner || !outer.IsAncestorOf(inner)) + continue; + var rel = outer.GlobalTransform.AffineInverse() * inner.GlobalTransform; + if (!_nestedRest.TryGetValue(inner, out var rest)) + { + _nestedRest[inner] = rel; + return true; + } + return rel.Origin.DistanceTo(rest.Origin) < 0.5f && rel.Basis.Z.Normalized().Dot(rest.Basis.Z.Normalized()) > 0.999f; + } + return false; + } +} diff --git a/CSVM/src/Effects/Ocean.cs.uid b/CSVM/src/Effects/Ocean.cs.uid new file mode 100644 index 000000000..e873eb6f7 --- /dev/null +++ b/CSVM/src/Effects/Ocean.cs.uid @@ -0,0 +1 @@ +uid://5qvckhmbw76s diff --git a/CSVM/src/Effects/OceanCalmZone.cs b/CSVM/src/Effects/OceanCalmZone.cs new file mode 100644 index 000000000..54bb8746d --- /dev/null +++ b/CSVM/src/Effects/OceanCalmZone.cs @@ -0,0 +1,91 @@ +using System; +using Godot; + +namespace CSVM.Effects; + +/// +/// One ship's calm zone on the wave ocean: a box on the water along the hull's heading. It holds +/// the hull's waterline and every wake sheet the hull trails. The shader keeps the waves flat inside +/// it and a margin past it, then fades them in. A zone grows from footprints in world space, so it +/// follows the wake sheets' own geometry and turns with the hull. +/// +internal struct OceanCalmZone +{ + private readonly Vector2 _origin; + private readonly Vector2 _u; + private readonly Vector2 _v; + private float _minU; + private float _maxU; + private float _minV; + private float _maxV; + + /// An empty zone on the water at (world X/Z), its long axis + /// along . A heading with no horizontal length falls back to +X. + public OceanCalmZone(Vector2 origin, Vector2 heading) + { + _origin = origin; + _u = heading.LengthSquared() > 1e-8f ? heading.Normalized() : Vector2.Right; + _v = new Vector2(-_u.Y, _u.X); + _minU = _minV = float.MaxValue; + _maxU = _maxV = float.MinValue; + } + + /// Whether no footprint has been added yet. + public readonly bool IsEmpty => _minU > _maxU; + + /// The unit axis along the heading, in world X/Z. + public readonly Vector2 Axis => _u; + + /// The box's centre in world X/Z; the origin while the zone is empty. + public readonly Vector2 Center => IsEmpty ? _origin + : _origin + (_u * ((_minU + _maxU) * 0.5f)) + (_v * ((_minV + _maxV) * 0.5f)); + + /// Half the box's length along the heading and half its width across it. + public readonly Vector2 HalfExtents => IsEmpty ? Vector2.Zero + : new Vector2((_maxU - _minU) * 0.5f, (_maxV - _minV) * 0.5f); + + /// The fraction of the wave height left at metres from a + /// zone: none within , all of it metres further, + /// on the shader's smoothstep. + public static float Calm(float distance, float margin, float fade) + { + float t = Math.Clamp((distance - margin) / Math.Max(fade, 1e-6f), 0f, 1f); + return t * t * (3f - (2f * t)); + } + + /// Grows the box over one world X/Z point. + public void Add(Vector2 point) + { + var d = point - _origin; + float u = d.Dot(_u), v = d.Dot(_v); + _minU = Math.Min(_minU, u); + _maxU = Math.Max(_maxU, u); + _minV = Math.Min(_minV, v); + _maxV = Math.Max(_maxV, v); + } + + /// Grows the box over the horizontal shadow of a local box under a transform: a mesh's + /// AABB and its global transform. + public void Add(Aabb box, Transform3D xf) + { + var p = box.Position; + var s = box.Size; + for (int i = 0; i < 8; i++) + { + var corner = new Vector3(p.X + ((i & 1) != 0 ? s.X : 0f), p.Y + ((i & 2) != 0 ? s.Y : 0f), p.Z + ((i & 4) != 0 ? s.Z : 0f)); + var w = xf * corner; + Add(new Vector2(w.X, w.Z)); + } + } + + /// The metres from to the box, zero inside it. The shader's + /// ship_calm computes the same. + public readonly float Distance(Vector2 point) + { + var d = point - Center; + var half = HalfExtents; + float ex = Math.Max(Math.Abs(d.Dot(_u)) - half.X, 0f); + float ey = Math.Max(Math.Abs(d.Dot(_v)) - half.Y, 0f); + return MathF.Sqrt((ex * ex) + (ey * ey)); + } +} diff --git a/CSVM/src/Effects/OceanCalmZone.cs.uid b/CSVM/src/Effects/OceanCalmZone.cs.uid new file mode 100644 index 000000000..a073559a8 --- /dev/null +++ b/CSVM/src/Effects/OceanCalmZone.cs.uid @@ -0,0 +1 @@ +uid://bjtpn0lrfyu1b diff --git a/CSVM/src/Effects/OceanMask.cs b/CSVM/src/Effects/OceanMask.cs new file mode 100644 index 000000000..0ce08be11 --- /dev/null +++ b/CSVM/src/Effects/OceanMask.cs @@ -0,0 +1,648 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; +using CSVM.Mech3; +using CSVM.Utils; +using Godot; +using Kind = CSVM.Effects.OceanMaskRaster.Kind; +using Tri = CSVM.Effects.OceanMaskRaster.Tri; + +namespace CSVM.Effects; + +/// +/// The wave ocean's shore mask, baked once from a built world's mesh instances. Its red channel is +/// sea coverage. Its green channel is the distance to the nearest shore, surf texel or solid object +/// near sea level. The ocean's shader fades the waves from it. The tint texture carries the base sheet's baked vertex +/// colour, which darkens the water around the islands. The bake also finds the wake sheets and the +/// base texture's tile size. turns the triangles into texels. +/// +internal sealed class OceanMask +{ + /// The texel edge in metres. + public const float Cell = OceanMaskRaster.Cell; + + /// The filtered sea coverage below which the ocean discards a fragment. The base sheet's + /// hide (csky_ocean.gdshaderinc) steps aside only at or above it, so every texel has one + /// owner. The grid shader is written from it; the include spells it as a literal, which the + /// graphics-ocean-switch suite holds to it. + public const float SeaThreshold = 0.02f; + + /// The height either side of y = 0 within which the base sheet's hide clears the sheet + /// over the sea. The include spells it as a literal, which the graphics-ocean-switch suite holds to it. + public const float HideHeight = 0.25f; + + /// The highest a base-sheet triangle with its foot at sea level may rise and still be a + /// ramp. A ramp is open sea in the mask, cleared by the hide up to this height. It sits above the + /// tallest harbour ramp and below C3's chute to its raised lake (docs/architecture/Effects.md, + /// OceanMask). The include spells it as a literal. + public const float RampTop = 4f; + + /// The zone byte of a texel on a seam between two zone groups, which no grid draws. + public const byte SeamZone = OceanMaskRaster.AnyZone - 1; + + // The globals csky_ocean.gdshaderinc declares: the live mask, its zone groups and its world rect. + private static readonly StringName MaskParam = "csky_ocean_mask"; + private static readonly StringName ZoneParam = "csky_ocean_zone"; + private static readonly StringName RectParam = "csky_ocean_rect"; + + // One bake per built world, held only as long as its builder lives. + private static readonly ConditionalWeakTable Baked = new(); + + // What the globals hold with no ocean live: no sea, and a zone every grid draws. The hide then + // steps aside nowhere, whatever csky_ocean_on reads. + private static ImageTexture? _noSea; + private static ImageTexture? _anyZone; + + private OceanMask() + { + } + + /// The mask the base sheet's hide reads now, null while the defaults stand. Read by the + /// suites, since the server's getter for a global errors outside the editor. + public static OceanMask? Live { get; private set; } + + public ImageTexture Texture { get; private set; } = null!; + + public Image Image { get; private set; } = null!; + + public ImageTexture TintTexture { get; private set; } = null!; + + /// The tint texture's pixels, kept for --dump-ocean-mask. + public Image TintImage { get; private set; } = null!; + + /// The height the ocean rises to over a ramp (R8 over ), one + /// zero texel when the world has no ramp. + public ImageTexture LiftTexture { get; private set; } = null!; + + /// The lift texture's pixels, kept for . + public Image LiftImage { get; private set; } = null!; + + /// The world X/Z of the mask's first texel corner. + public Vector2 Origin { get; private set; } + + /// The mask's extent in metres along X and Z. + public Vector2 Size { get; private set; } + + public int Width { get; private set; } + + public int Height { get; private set; } + + /// The texture most of the base sheet's triangles carry. + public string BaseTexture { get; private set; } = ""; + + /// The metres one repeat of the base texture spans, the median over the base sheet. + public float TileMetres { get; private set; } = 32f; + + public int BaseTriangles { get; private set; } + + /// The lowest draw priority any sea-level base-sheet surface carries. The ocean draws a + /// level below it, under every coplanar layer drawn on or beside any part of the sheet. + public int BaseLevel { get; private set; } + + public int EdgeTriangles { get; private set; } + + /// The base-sheet triangles rising off sea level below , counted + /// in too. + public int RampTriangles { get; private set; } + + public int SolidTriangles { get; private set; } + + /// The visual layer sets the base sheets are drawn on, one per zone group. The zone + /// gate culls a whole group per camera, so the ocean over it draws on the same layers. + public List ZoneLayers { get; } = new(); + + /// Which zone group each texel shows (R8), or null when the sheet is one group. + public ImageTexture? ZoneTexture { get; private set; } + + /// The wake sheets' meshes found in the world, whose ships the ocean calms around. + public List Wakes { get; } = new(); + + /// The movers the bake found to be hulls on the sea (). They stay + /// out of the mask, so a hull that sails off leaves no calm patch. + public List MovingHulls { get; } = new(); + + /// The movers hidden at the bake, which the ocean judges once they show. + public List UnjudgedMovers { get; } = new(); + + /// The bake's triangles under moving hulls, kept out of the mask. + public int MovingHullTriangles { get; private set; } + + /// The bake's milliseconds per phase, for the build log line. + public string Timing { get; private set; } = ""; + + /// Declares the mask globals with their defaults. Must run before the first shader that + /// reads them is built, as does. + public static void RegisterGlobals() + { + _noSea ??= TextureUpload.Create(1, 1, Image.Format.Rg8, new byte[] { 0, 0 }); + _anyZone ??= TextureUpload.Create(1, 1, Image.Format.R8, new[] { OceanMaskRaster.AnyZone }); + RenderingServer.GlobalShaderParameterAdd(MaskParam, RenderingServer.GlobalShaderParameterType.Sampler2D, _noSea); + RenderingServer.GlobalShaderParameterAdd(ZoneParam, RenderingServer.GlobalShaderParameterType.Sampler2D, _anyZone); + RenderingServer.GlobalShaderParameterAdd(RectParam, RenderingServer.GlobalShaderParameterType.Vec4, new Vector4(0f, 0f, 1f, 1f)); + } + + /// Puts the defaults back, so the base sheet's hide steps aside nowhere. + public static void Withdraw() + { + Live = null; + if (_noSea == null || _anyZone == null) + return; + RenderingServer.GlobalShaderParameterSet(MaskParam, _noSea); + RenderingServer.GlobalShaderParameterSet(ZoneParam, _anyZone); + RenderingServer.GlobalShaderParameterSet(RectParam, new Vector4(0f, 0f, 1f, 1f)); + } + + + /// Bakes the mask over the world under , or null when that world + /// has no sea-level base sheet. It skips the subtrees in and the + /// found to be hulls on the sea. The surfaces are read through + /// , which holds the arrays it committed. + public static OceanMask? Bake(Node3D root, SceneBuilder scene, ISet skip, IReadOnlyCollection movers) + { + // The world is static. An ocean rebuilt over it, after a live switch or a Water Quality + // change, reuses the first bake. The frame path never pays it twice. + if (Baked.TryGetValue(scene, out var kept) && kept.Root == root && GodotObject.IsInstanceValid(root)) + { + kept.Mask?.Wakes.RemoveAll(w => !GodotObject.IsInstanceValid(w)); + kept.Mask?.MovingHulls.RemoveAll(h => !GodotObject.IsInstanceValid(h)); + kept.Mask?.UnjudgedMovers.RemoveAll(h => !GodotObject.IsInstanceValid(h)); + if (kept.Mask != null) + kept.Mask.Timing = "reused"; + return kept.Mask; + } + var mask = BakeNew(root, scene, skip, movers); + // ⚠ Do not drop this collection. Reading MeshInstance3D.Mesh mints a managed wrapper for + // every mesh whose wrapper was already collected, and each holds its mesh until collected. + // A short run that quits first leaves hundreds alive, and Godot's exit check aborts. + long gcStart = System.Diagnostics.Stopwatch.GetTimestamp(); + GC.Collect(); + if (mask != null) + mask.Timing += string.Create(CultureInfo.InvariantCulture, + $" gc={System.Diagnostics.Stopwatch.GetElapsedTime(gcStart).TotalMilliseconds:0}"); + Baked.AddOrUpdate(scene, new Memo(root, mask)); + return mask; + } + + /// Hands this mask to the base sheet's hide, which then steps aside exactly where the + /// ocean draws. A sheet shown after the bake lies off the sea coverage and keeps drawing. + public void Publish() + { + if (_anyZone == null) + return; + Live = this; + RenderingServer.GlobalShaderParameterSet(MaskParam, Texture); + RenderingServer.GlobalShaderParameterSet(ZoneParam, ZoneTexture ?? _anyZone); + RenderingServer.GlobalShaderParameterSet(RectParam, new Vector4(Origin.X, Origin.Y, 1f / Size.X, 1f / Size.Y)); + } + + /// Whether the texel under a world point counts as sea, at the coverage the ocean draws + /// from (). A point off the mask is not sea. + public bool IsSea(Vector3 world) + { + int x = (int)Math.Floor((world.X - Origin.X) / Cell); + int y = (int)Math.Floor((world.Z - Origin.Y) / Cell); + return x >= 0 && y >= 0 && x < Width && y < Height && Image.GetPixel(x, y).R >= SeaThreshold; + } + + /// Whether the base sheet's hide clears a sheet fragment at a world point. It reads the mask + /// as csky_ocean_hides_sea does: filtered coverage, the height window, a ramp's taller one. + /// A zone seam is not read; the suites hold the include to this. + public bool ClearsSheet(Vector3 world) + { + if (world.Y <= -HideHeight || world.Y >= RampTop) + return false; + float tx = ((world.X - Origin.X) / Cell) - 0.5f, ty = ((world.Z - Origin.Y) / Cell) - 0.5f; + int ix = (int)Math.Floor(tx), iy = (int)Math.Floor(ty); + float fx = tx - ix, fy = ty - iy; + float a = Red(Image, ix, iy), b = Red(Image, ix + 1, iy), c = Red(Image, ix, iy + 1), d = Red(Image, ix + 1, iy + 1); + float sea = Mathf.Lerp(Mathf.Lerp(a, b, fx), Mathf.Lerp(c, d, fx), fy) / 255f; + bool ramp = a == OceanMaskRaster.RampSea || b == OceanMaskRaster.RampSea + || c == OceanMaskRaster.RampSea || d == OceanMaskRaster.RampSea; + return sea >= SeaThreshold && (world.Y < HideHeight || ramp); + } + + /// The height the calm ocean stands at over a world X/Z, sea level away from a ramp. It is + /// filtered as the grid's vertex stage reads the lift. + public float LiftAt(Vector3 world) + { + if (LiftImage.GetWidth() == 1) + return 0f; + float tx = ((world.X - Origin.X) / Cell) - 0.5f, ty = ((world.Z - Origin.Y) / Cell) - 0.5f; + int ix = (int)Math.Floor(tx), iy = (int)Math.Floor(ty); + float fx = tx - ix, fy = ty - iy; + float a = Red(LiftImage, ix, iy), b = Red(LiftImage, ix + 1, iy), c = Red(LiftImage, ix, iy + 1), d = Red(LiftImage, ix + 1, iy + 1); + return Mathf.Lerp(Mathf.Lerp(a, b, fx), Mathf.Lerp(c, d, fx), fy) / 255f * RampTop; + } + + // The red byte of a texel, clamped to the edge as the include's fetch is. + private static float Red(Image image, int x, int y) => + Mathf.Round(image.GetPixel(Math.Clamp(x, 0, image.GetWidth() - 1), Math.Clamp(y, 0, image.GetHeight() - 1)).R * 255f); + + private static OceanMask? BakeNew(Node3D root, SceneBuilder scene, ISet skip, IReadOnlyCollection movers) + { + long t0 = Stopwatch.GetTimestamp(); + var names = new Dictionary(); + foreach (var (mat, tex) in scene.TexturedMaterials) + names[mat] = tex; + var result = new OceanMask(); + var walker = new Walker(root, scene, names, skip, movers, result); + walker.Walk(); + double walkMs = Lap(ref t0); + if (result.BaseTriangles == 0) + return null; + int best = 0; + foreach (var (name, count) in walker.BaseCounts) + { + if (count > best) + { + best = count; + result.BaseTexture = name; + } + } + var uvSamples = walker.UvSamples; + if (uvSamples.Count > 0) + { + uvSamples.Sort(); + result.TileMetres = uvSamples[uvSamples.Count / 2]; + } + var vertexColor = Colors.White; + if (walker.ColorCount > 0) + { + int cn = walker.ColorCount; + vertexColor = new Color((float)(walker.R / cn), (float)(walker.G / cn), (float)(walker.B / cn)).SrgbToLinear(); + } + var raster = OceanMaskRaster.Run(walker.Tris.ToArray(), vertexColor.LinearToSrgb(), OceanMaskRaster.DefaultBands); + Lap(ref t0); + result.Width = raster.Width; + result.Height = raster.Height; + result.Origin = raster.Origin; + result.Size = new Vector2(raster.Width * Cell, raster.Height * Cell); + result.Image = Image.CreateFromData(raster.Width, raster.Height, false, Image.Format.Rg8, raster.Mask); + result.TintImage = Image.CreateFromData(raster.Width, raster.Height, false, Image.Format.Rgb8, raster.Tint); + // Both kept: --dump-ocean-mask reads them after the upload. + result.TintTexture = TextureUpload.Create(result.TintImage, callerKeeps: true); + result.Texture = TextureUpload.Create(result.Image, callerKeeps: true); + if (result.ZoneLayers.Count > 1) + result.ZoneTexture = TextureUpload.Create(raster.Width, raster.Height, Image.Format.R8, Seam(raster)); + result.LiftImage = result.RampTriangles > 0 + ? Image.CreateFromData(raster.Width, raster.Height, false, Image.Format.R8, raster.Lift) + : Image.CreateFromData(1, 1, false, Image.Format.R8, new byte[] { 0 }); + result.LiftTexture = TextureUpload.Create(result.LiftImage, callerKeeps: true); + double uploadMs = Lap(ref t0); + result.Timing = string.Create(CultureInfo.InvariantCulture, + $"walk={walkMs:0.0} raster={raster.FillMs:0.0} distance={raster.EncodeMs:0.0} upload={uploadMs:0.0} bands={raster.Bands}"); + return result; + } + + // Milliseconds since t0, which moves to now. + private static double Lap(ref long t0) + { + long now = Stopwatch.GetTimestamp(); + double ms = (now - t0) * 1000.0 / Stopwatch.Frequency; + t0 = now; + return ms; + } + + // Godot keeps a vertex colour as RGBA8, truncated, and a read-back returns those bytes over 255. + // The builder's kept arrays hold the committed floats, so they take the same step here. The tint + // then matches either source, and a stored value passes through unchanged. + private static Color Stored(Color c) => new( + (byte)Math.Clamp(c.R * 255f, 0f, 255f) / 255f, + (byte)Math.Clamp(c.G * 255f, 0f, 255f) / 255f, + (byte)Math.Clamp(c.B * 255f, 0f, 255f) / 255f, + (byte)Math.Clamp(c.A * 255f, 0f, 255f) / 255f); + + // The zone groups with every texel on a seam between two groups taken from both. A seam texel's + // centre can fall on either side, so either grid would overhang the other group's culled sea. + // C5 lays an opaque fog gradient along each seam, which hides the gap this leaves. + private static byte[] Seam(OceanMaskRaster.Result raster) + { + int w = raster.Width, h = raster.Height; + var zones = raster.Zones; + var mask = raster.Mask; + var seamed = (byte[])zones.Clone(); + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + int i = (y * w) + x; + byte z = zones[i]; + if (mask[2 * i] == 0 || z >= SeamZone) + continue; + for (int d = 0; d < 4; d++) + { + int nx = x + (d == 0 ? 1 : d == 1 ? -1 : 0), ny = y + (d == 2 ? 1 : d == 3 ? -1 : 0); + if (nx < 0 || ny < 0 || nx >= w || ny >= h) + continue; + int n = (ny * w) + nx; + if (mask[2 * n] != 0 && zones[n] < SeamZone && zones[n] != z) + seamed[i] = SeamZone; + } + } + } + return seamed; + } + + // The authored priority a world material was built at. Its depth bias adds under half a level + // on top (rank, no_clutter), so the floor a quarter level up recovers it. + private static int LevelOf(Material material) => + material is ShaderMaterial sm + ? (int)Math.Floor((sm.GetShaderParameter("depth_bias").AsSingle() / SceneBuilder.DepthBiasPerLevel) + 0.25f) + : 0; + + // A world's bake, or null when it had no sea-level base sheet. + private sealed record Memo(Node3D Root, OceanMask? Mask); + + // One pass over the world tree finds the wake sheets and lists the surfaces to read, in tree + // order. The triangles are read after it, once every hull subtree is known and dropped. + private sealed class Walker + { + // A wake sheet lies on the water; the freighter's bow wave climbs its hull about 10 m. + private const float WakeMaxHeight = 20f; + + private readonly Node3D _root; + private readonly SceneBuilder _scene; + private readonly Dictionary _names; + private readonly ISet _skip; + private readonly OceanMask _result; + private readonly List _surfaces = new(); + private readonly Dictionary _moverIndex = new(); + private readonly Mover[] _movers; + private readonly List _moverPath = new(); + private readonly List _triMovers = new(); + private int[] _pathArray = Array.Empty(); + + public Walker(Node3D root, SceneBuilder scene, Dictionary names, ISet skip, + IReadOnlyCollection movers, OceanMask result) + { + _root = root; + _scene = scene; + _names = names; + _skip = skip; + _result = result; + _movers = new Mover[movers.Count]; + foreach (var m in movers) + { + if (m != root && GodotObject.IsInstanceValid(m) && !_moverIndex.ContainsKey(m)) + { + _movers[_moverIndex.Count] = new Mover { Node = m }; + _moverIndex[m] = _moverIndex.Count; + } + } + } + + public List Tris { get; } = new(); + + public List UvSamples { get; } = new(); + + public Dictionary BaseCounts { get; } = new(); + + public double R { get; private set; } + + public double G { get; private set; } + + public double B { get; private set; } + + public int ColorCount { get; private set; } + + public void Walk() + { + Visit(_root, Transform3D.Identity, true); + foreach (var s in _surfaces) + Read(s); + if (_moverIndex.Count > 0) + DropMovingHulls(); + } + + private static bool IsWakeTexture(string tex) => + tex.Contains("wakefront", StringComparison.OrdinalIgnoreCase) + || tex.Contains("wakeback", StringComparison.OrdinalIgnoreCase); + + // How many levels up the ship of a wake below sits: a sheet's mesh is 2, under its own node + // (1), under the ship. Wakes are found everywhere, skipped subtrees included. The ship gives + // up its surfaces, so a moving hull leaves no calm patch where it started. A wake node right + // under the root gives up only its own. A subtree's surfaces are the tail it appended. + private int Visit(Node node, Transform3D parent, bool collect) + { + // A hidden subtree draws nothing: C3's unplaced swtr sheets over its crater floor, and + // every unplaced vehicle parked at the origin. + collect = collect && !_skip.Contains(node) && node is not Node3D { Visible: false }; + int start = _surfaces.Count; + var xf = parent; + if (collect && node is Node3D n3 && node != _root) + xf = parent * n3.Transform; + bool mover = false; + if (collect && _moverIndex.TryGetValue(node, out int moverAt)) + { + mover = true; + _movers[moverAt].Collected = true; + _movers[moverAt].Origin = xf.Origin; + _moverPath.Add(moverAt); + _pathArray = _moverPath.ToArray(); + } + bool wake = false; + if (node is MeshInstance3D mi && mi.Mesh is { } mesh) + { + var arrayMesh = collect ? mesh as ArrayMesh : null; + int count = mesh.GetSurfaceCount(); + // C1's rock zeppelin wears wakefront1 over 180 m of its underside; a sheet lies flat. + bool flat = mesh.GetAabb().Size.Y < WakeMaxHeight; + for (int s = 0; s < count; s++) + { + var mat = mi.GetSurfaceOverrideMaterial(s) ?? mesh.SurfaceGetMaterial(s); + if (mat == null || !_names.TryGetValue(mat, out var tex)) + continue; + if (!wake && flat && IsWakeTexture(tex)) + { + wake = true; + _result.Wakes.Add(mi); + } + if (arrayMesh != null) + _surfaces.Add(new Surface(arrayMesh, s, tex, xf, mat, mi.Layers, _pathArray)); + } + } + int below = 0; + int children = node.GetChildCount(); + for (int i = 0; i < children; i++) + below = Math.Max(below, Visit(node.GetChild(i), xf, collect)); + if (mover) + { + _moverPath.RemoveAt(_moverPath.Count - 1); + _pathArray = _moverPath.ToArray(); + } + if (below > 0 && node != _root) + _surfaces.RemoveRange(start, _surfaces.Count - start); + return wake ? 2 : Math.Max(below - 1, 0); + } + + private void Read(Surface surface) + { + var (mesh, s, tex, xf, material, layers, movers) = surface; + bool water = SceneBuilder.ClassifySurface(tex) == "water"; + bool isBase = water && SceneBuilder.IsOceanBaseTexture(tex); + // The builder's own copy: Godot's read-back copies the surface off the GPU and waits + // for the render thread, about a millisecond a surface. UVs and colours serve the base. + var arrays = _scene.SurfaceArrays(mesh, s); + var v = arrays[(int)Mesh.ArrayType.Vertex].AsVector3Array(); + var ix = arrays[(int)Mesh.ArrayType.Index].VariantType == Variant.Type.Nil + ? Array.Empty() : arrays[(int)Mesh.ArrayType.Index].AsInt32Array(); + var uv = !isBase || arrays[(int)Mesh.ArrayType.TexUV].VariantType == Variant.Type.Nil + ? Array.Empty() : arrays[(int)Mesh.ArrayType.TexUV].AsVector2Array(); + var col = !isBase || arrays[(int)Mesh.ArrayType.Color].VariantType == Variant.Type.Nil + ? Array.Empty() : arrays[(int)Mesh.ArrayType.Color].AsColorArray(); + int triCount = ix.Length > 0 ? ix.Length / 3 : v.Length / 3; + for (int t = 0; t < triCount; t++) + { + int i0 = ix.Length > 0 ? ix[3 * t] : 3 * t; + int i1 = ix.Length > 0 ? ix[(3 * t) + 1] : (3 * t) + 1; + int i2 = ix.Length > 0 ? ix[(3 * t) + 2] : (3 * t) + 2; + var a = xf * v[i0]; + var b = xf * v[i1]; + var c = xf * v[i2]; + float minY = Math.Min(a.Y, Math.Min(b.Y, c.Y)); + float maxY = Math.Max(a.Y, Math.Max(b.Y, c.Y)); + bool seaLevel = minY > -0.5f && maxY < 0.5f; + bool foot = minY > -0.5f && minY < 0.5f; + // A base triangle rising off the sea to a quay is sea the hide clears whole. As surf + // water its footprint drew calm and in the mean tint, a dark patch in the waves. + bool ramp = isBase && foot && maxY >= HideHeight && maxY < RampTop; + if (movers.Length > 0 && OceanMovers.ReachesWaterline(a, b, c)) + { + foreach (int m in movers) + _movers[m].Waterline = true; + } + Kind kind; + if (isBase && (seaLevel || ramp)) + { + kind = Kind.Base; + foreach (int m in movers) + _movers[m].HasBase = true; + int level = LevelOf(material); + if (_result.BaseTriangles++ == 0 || level < _result.BaseLevel) + _result.BaseLevel = level; + BaseCounts[tex] = BaseCounts.GetValueOrDefault(tex) + 1; + // A ramp leaves the tile and the mean tint to the sea-level sheet, so the open + // sea away from it keeps its bytes. + if (ramp) + _result.RampTriangles++; + if (!ramp && uv.Length > 0) + { + float dp = new Vector2(b.X - a.X, b.Z - a.Z).Length(); + float du = (uv[i1] - uv[i0]).Length(); + if (du > 1e-4f) + UvSamples.Add(dp / du); + } + if (!ramp && col.Length > 0) + { + var c0 = Stored(col[i0]); + R += c0.R; + G += c0.G; + B += c0.B; + ColorCount++; + } + } + else if (water && (seaLevel || (isBase && foot))) + { + // A base-sheet slope too tall for a ramp hides only below HideHeight, so the + // ocean must reach under its foot, calm. + kind = Kind.Edge; + _result.EdgeTriangles++; + } + else if (!water && minY < 20f && maxY - minY < 400f) + { + kind = Kind.Solid; + _result.SolidTriangles++; + } + else + { + continue; + } + bool tinted = kind == Kind.Base && col.Length > 0; + byte zone = kind == Kind.Base ? ZoneOf(layers) : (byte)0; + Tris.Add(new Tri(a, b, c, kind, tinted ? Stored(col[i0]) : Colors.White, tinted ? Stored(col[i1]) : Colors.White, tinted ? Stored(col[i2]) : Colors.White, zone, ramp)); + _triMovers.Add(movers); + } + } + + // The group of base sheets sharing one visual layer set, numbered in first-seen order. + private byte ZoneOf(uint layers) + { + int k = _result.ZoneLayers.IndexOf(layers); + if (k < 0) + { + k = _result.ZoneLayers.Count; + _result.ZoneLayers.Add(layers); + } + return (byte)Math.Min(k, OceanMaskRaster.AnyZone - 2); + } + + // A mover is a hull when its origin sits on sea level over the world's own sea-level water + // and its meshes reach the waterline. A mover carrying base-sheet water is sea, not a hull. + // Its triangles and every nested mover's leave the mask together; triangle order is kept. + private void DropMovingHulls() + { + var hull = new bool[_moverIndex.Count]; + for (int m = 0; m < hull.Length; m++) + { + ref var mover = ref _movers[m]; + if (!mover.Collected) + { + _result.UnjudgedMovers.Add(mover.Node); + continue; + } + if (!mover.Waterline || mover.HasBase || Math.Abs(mover.Origin.Y) > OceanMovers.OriginBand) + { + Log.Debug("world", $"ocean: mover '{mover.Node.Name}' at ({mover.Origin.X:0},{mover.Origin.Y:0.0},{mover.Origin.Z:0}) not a hull: waterline={mover.Waterline} base={mover.HasBase}"); + continue; + } + var p = new Vector2(mover.Origin.X, mover.Origin.Z); + for (int i = 0; i < Tris.Count; i++) + { + var t = Tris[i]; + if (t.Kind != Kind.Solid && _triMovers[i].Length == 0 && OceanMovers.Covers(t.A, t.B, t.C, p)) + { + hull[m] = true; + _result.MovingHulls.Add(mover.Node); + break; + } + } + if (!hull[m]) + Log.Debug("world", $"ocean: mover '{mover.Node.Name}' at ({mover.Origin.X:0},{mover.Origin.Y:0.0},{mover.Origin.Z:0}) not a hull: off the sea"); + } + if (_result.MovingHulls.Count == 0) + return; + int kept = 0; + for (int i = 0; i < Tris.Count; i++) + { + bool drop = false; + foreach (int m in _triMovers[i]) + drop |= hull[m]; + if (!drop) + { + Tris[kept++] = Tris[i]; + continue; + } + _result.MovingHullTriangles++; + if (Tris[i].Kind == Kind.Solid) + _result.SolidTriangles--; + else if (Tris[i].Kind == Kind.Edge) + _result.EdgeTriangles--; + } + Tris.RemoveRange(kept, Tris.Count - kept); + } + + private readonly record struct Surface(ArrayMesh Mesh, int Index, string Texture, Transform3D Xf, Material Material, uint Layers, int[] Movers); + + // One mover as the walk found it: where it stood, and what its visible meshes reach. + private struct Mover + { + public Node3D Node; + public Vector3 Origin; + public bool Collected; + public bool Waterline; + public bool HasBase; + } + } +} diff --git a/CSVM/src/Effects/OceanMask.cs.uid b/CSVM/src/Effects/OceanMask.cs.uid new file mode 100644 index 000000000..14427844e --- /dev/null +++ b/CSVM/src/Effects/OceanMask.cs.uid @@ -0,0 +1 @@ +uid://cw1ewv75q5bm3 diff --git a/CSVM/src/Effects/OceanMaskRaster.cs b/CSVM/src/Effects/OceanMaskRaster.cs new file mode 100644 index 000000000..915b40e67 --- /dev/null +++ b/CSVM/src/Effects/OceanMaskRaster.cs @@ -0,0 +1,449 @@ +using System; +using System.Threading.Tasks; +using Godot; + +namespace CSVM.Effects; + +/// +/// The compute half of the ocean's shore mask: world-space sea-level triangles in, the mask's and +/// the tint's texel bytes out. It touches no engine object, so it runs on the thread pool in row +/// bands. The bytes are identical for any band count. collects the +/// triangles from the built world and uploads the bytes. +/// +internal static class OceanMaskRaster +{ + /// The texel edge in metres. Fine enough that the calm band hugs the surf ring. + public const float Cell = 8f; + + /// The zone byte of a texel every zone group's ocean draws: water the gate never splits. + public const byte AnyZone = 255; + + /// The mask's red byte on a texel a ramp's open sea covers, where the open sea's is 255. + /// The base sheet's hide clears a ramp up to where any texel its + /// filter reads holds it (csky_ocean.gdshaderinc spells it as a literal). Coverage reads it + /// as sea, as it reads 255. + public const byte RampSea = 254; + + /// The shore distance in metres at which the mask's G saturates. G is the distance to + /// the nearest texel that is not open base sea (a shore, surf or solid texel) over this. + public const float ShoreReach = 160f; + + // Rows a band reads past its own edges in the distance pass. Every distance below ShoreReach is + // a chain of at most ShoreReach / Cell steps, so a band sees every chain its own rows can take. + private const int Halo = (int)(ShoreReach / Cell) + 4; + + // How far past a ramp its plane is lifted. It reaches the nearest texel centre beyond the rim, + // so the linear filter reads the plane on both sides of it. + private const double LiftReach = Cell * 1.5; + + // Below this many rows a band's halo costs more than its thread saves. + private const int MinBandRows = 64; + + /// What a triangle marks: open sea, the surf ring's water, or a solid near sea level. + public enum Kind + { + Base, + Edge, + Solid, + } + + /// The band count the bake uses on this machine. + public static int DefaultBands => Math.Clamp(System.Environment.ProcessorCount, 1, 16); + + /// Rasterises into a mask over the base and edge triangles' + /// extent. is the sRGB tint every texel off the base sheet takes. + public static Result Run(Tri[] tris, Color mean, int bands) + { + float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue; + foreach (var t in tris) + { + if (t.Kind == Kind.Solid) + continue; + minX = Math.Min(minX, Math.Min(t.A.X, Math.Min(t.B.X, t.C.X))); + maxX = Math.Max(maxX, Math.Max(t.A.X, Math.Max(t.B.X, t.C.X))); + minZ = Math.Min(minZ, Math.Min(t.A.Z, Math.Min(t.B.Z, t.C.Z))); + maxZ = Math.Max(maxZ, Math.Max(t.A.Z, Math.Max(t.B.Z, t.C.Z))); + } + minX = Math.Max(minX, -30000f); + minZ = Math.Max(minZ, -30000f); + maxX = Math.Min(maxX, 30000f); + maxZ = Math.Min(maxZ, 30000f); + // No spare texel past the last polygon: clamp-to-edge carries the border sea outward. + int w = Math.Max(1, (int)Math.Ceiling((maxX - minX) / Cell)); + int h = Math.Max(1, (int)Math.Ceiling((maxZ - minZ) / Cell)); + var grid = new Grid(w, h, minX, minZ); + var mask = new byte[w * h * 2]; + var tint = new byte[w * h * 3]; + var lift = new byte[w * h]; + int count = Math.Clamp(Math.Min(bands, h / MinBandRows), 1, h); + int rows = (h + count - 1) / count; + long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); + // Each triangle's rows, once, so a band passes over the triangles it cannot touch cheaply. + var spans = new (int Lo, int Hi)[tris.Length]; + for (int i = 0; i < tris.Length; i++) + { + var t = tris[i]; + spans[i] = ((int)Math.Floor((Math.Min(t.A.Z, Math.Min(t.B.Z, t.C.Z)) - minZ) / Cell), + (int)Math.Ceiling((Math.Max(t.A.Z, Math.Max(t.B.Z, t.C.Z)) - minZ) / Cell)); + } + Parallel.For(0, count, b => Fill(tris, spans, grid, b * rows, Math.Min(h, (b + 1) * rows) - 1, mean, tint)); + Parallel.For(0, count, b => Dilate(grid, b * rows, Math.Min(h, (b + 1) * rows) - 1, tint)); + long t1 = System.Diagnostics.Stopwatch.GetTimestamp(); + Parallel.For(0, count, b => Encode(grid, b * rows, Math.Min(h, (b + 1) * rows) - 1, mask, lift)); + long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); + double tick = 1000.0 / System.Diagnostics.Stopwatch.Frequency; + return new Result(w, h, new Vector2(minX, minZ), mask, tint, (t1 - t0) * tick, (t2 - t1) * tick, count, grid.Zone, lift); + } + + /// (byte)Math.Clamp(Math.Round(v * 255f), 0, 255), banker's rounding included, in float + /// alone. Below 256 the fraction s - r is exact, so every comparison sees the true value. + internal static byte Channel(float v) + { + float s = v * 255f; + if (!(s > 0f)) + return 0; + if (s >= 255f) + return 255; + int r = (int)s; + float f = s - r; + if (f > 0.5f || (f == 0.5f && (r & 1) == 1)) + r++; + return (byte)r; + } + + // Every triangle's texels within rows r0..r1, in list order. A later base triangle's tint wins + // wherever two overlap, as it does in one sequential pass. + private static void Fill(Tri[] tris, (int Lo, int Hi)[] spans, Grid g, int r0, int r1, Color mean, byte[] tint) + { + int w = g.W; + var tintF = new Color[w * (r1 - r0 + 1)]; + Array.Fill(tintF, new Color(-1f, 0f, 0f)); + for (int i = 0; i < tris.Length; i++) + { + if (spans[i].Hi >= r0 && spans[i].Lo <= r1) + Raster(tris[i], g, r0, r1, tintF); + if (tris[i].Ramp && spans[i].Hi + 2 >= r0 && spans[i].Lo - 2 <= r1) + Lift(tris[i], g, r0, r1); + } + // Open texels take the mean, rounded through Channel as a tinted texel is. The bytes do not + // depend on how the rows split into bands. + byte mr = Channel(mean.R), mg = Channel(mean.G), mb = Channel(mean.B); + for (int k = 0; k < tintF.Length; k++) + { + var c = tintF[k]; + int o = 3 * ((r0 * w) + k); + bool open = c.R < 0f; + g.Tinted[(r0 * w) + k] = !open; + tint[o] = open ? mr : Channel(c.R); + tint[o + 1] = open ? mg : Channel(c.G); + tint[o + 2] = open ? mb : Channel(c.B); + } + } + + // An open texel beside the base sheet takes its tinted neighbours' mean. The shader filters the + // tint linearly. Without this the sheet's last half texel blends toward the mean colour, a line + // where an opaque coast tile meets it. Reads only tinted texels, which no band writes here, so + // the bytes do not depend on the band count. + private static void Dilate(Grid g, int r0, int r1, byte[] tint) + { + int w = g.W; + for (int z = r0; z <= r1; z++) + { + for (int x = 0; x < w; x++) + { + int i = (z * w) + x; + if (g.Tinted[i]) + continue; + int n = 0, sr = 0, sg = 0, sb = 0; + for (int dz = -1; dz <= 1; dz++) + { + for (int dx = -1; dx <= 1; dx++) + { + int nx = x + dx, nz = z + dz; + if (nx < 0 || nz < 0 || nx >= w || nz >= g.H || !g.Tinted[(nz * w) + nx]) + continue; + int o = 3 * ((nz * w) + nx); + sr += tint[o]; + sg += tint[o + 1]; + sb += tint[o + 2]; + n++; + } + } + if (n == 0) + continue; + tint[3 * i] = (byte)((sr + (n / 2)) / n); + tint[(3 * i) + 1] = (byte)((sg + (n / 2)) / n); + tint[(3 * i) + 2] = (byte)((sb + (n / 2)) / n); + } + } + } + + // The barycentric test and the tint blend are spelled out in scalars. They keep the operation + // order of the Vector2 and Color operators they stand for, so every texel rounds the same. + private static void Raster(in Tri t, Grid g, int r0, int r1, Color[] tint) + { + float minX = g.MinX, minZ = g.MinZ; + int w = g.W; + float ax = t.A.X, ay = t.A.Z, bx = t.B.X, by = t.B.Z, cx = t.C.X, cy = t.C.Z; + int z0 = Math.Max(r0, (int)Math.Floor((Math.Min(ay, Math.Min(by, cy)) - minZ) / Cell)); + int z1 = Math.Min(r1, (int)Math.Ceiling((Math.Max(ay, Math.Max(by, cy)) - minZ) / Cell)); + // A triangle smaller than a texel still marks the texels its corners land in. + if (t.Kind == Kind.Solid) + { + Corner(g, (int)((ax - minX) / Cell), (int)((ay - minZ) / Cell), r0, r1); + Corner(g, (int)((bx - minX) / Cell), (int)((by - minZ) / Cell), r0, r1); + Corner(g, (int)((cx - minX) / Cell), (int)((cy - minZ) / Cell), r0, r1); + } + if (z0 > z1) + return; + float abx = bx - ax, aby = by - ay; + float bcx = cx - bx, bcy = cy - by; + float cax = ax - cx, cay = ay - cy; + float area = (abx * (cy - ay)) - (aby * (cx - ax)); + if (Math.Abs(area) < 1e-6f) + return; + int x0 = Math.Max(0, (int)Math.Floor((Math.Min(ax, Math.Min(bx, cx)) - minX) / Cell)); + int x1 = Math.Min(w - 1, (int)Math.Ceiling((Math.Max(ax, Math.Max(bx, cx)) - minX) / Cell)); + if (x0 > x1) + return; + double reach = (x1 - x0 + 2) * Cell; + for (int z = z0; z <= z1; z++) + { + float pz = minZ + ((z + 0.5f) * Cell); + float paz = pz - ay, pbz = pz - by, pcz = pz - cy; + double lo = double.NegativeInfinity, hi = double.PositiveInfinity; + Clip(abx, aby, paz, ax, area, reach, ref lo, ref hi); + Clip(bcx, bcy, pbz, bx, area, reach, ref lo, ref hi); + Clip(cax, cay, pcz, cx, area, reach, ref lo, ref hi); + int xs = (int)Math.Clamp(Math.Floor(((lo - minX) / Cell) - 0.5) - 1, x0, x1 + 1); + int xe = (int)Math.Clamp(Math.Ceiling(((hi - minX) / Cell) - 0.5) + 1, x0 - 1, x1); + for (int x = xs; x <= xe; x++) + { + float px = minX + ((x + 0.5f) * Cell); + float w0 = ((abx * paz) - (aby * (px - ax))) / area; + if (w0 < -1e-4f) + continue; + float w1 = ((bcx * pbz) - (bcy * (px - bx))) / area; + if (w1 < -1e-4f) + continue; + float w2 = ((cax * pcz) - (cay * (px - cx))) / area; + if (w2 < -1e-4f) + continue; + int i = (z * w) + x; + switch (t.Kind) + { + case Kind.Base: + if (g.Sea[i] == 0) + g.Sea[i] = 1; + if (g.Zone[i] != AnyZone) + g.Zone[i] = t.Zone; + if (t.Ramp) + g.Ramp[i] = true; + tint[((z - r0) * w) + x] = new Color( + (t.CA.R * w1) + (t.CB.R * w2) + (t.CC.R * w0), + (t.CA.G * w1) + (t.CB.G * w2) + (t.CC.G * w0), + (t.CA.B * w1) + (t.CB.B * w2) + (t.CC.B * w0)); + break; + case Kind.Edge: + g.Sea[i] = 2; + g.Zone[i] = AnyZone; + break; + default: + g.Solid[i] = true; + break; + } + } + } + } + + // A ramp's plane height at every texel centre within LiftReach of it, held to the ramp's own + // heights; the highest plane wins. Sampled linearly, the ocean then meets the plane at the + // ramp's rim and reaches the wall the ramp rises to, with no gap under it. + private static void Lift(in Tri t, Grid g, int r0, int r1) + { + double ax = t.A.X, az = t.A.Z, bx = t.B.X, bz = t.B.Z, cx = t.C.X, cz = t.C.Z; + double area = ((bx - ax) * (cz - az)) - ((bz - az) * (cx - ax)); + if (Math.Abs(area) < 1e-6) + return; + double top = Math.Max(t.A.Y, Math.Max(t.B.Y, t.C.Y)); + int x0 = Math.Max(0, (int)Math.Floor((Math.Min(ax, Math.Min(bx, cx)) - LiftReach - g.MinX) / Cell)); + int x1 = Math.Min(g.W - 1, (int)Math.Ceiling((Math.Max(ax, Math.Max(bx, cx)) + LiftReach - g.MinX) / Cell)); + int z0 = Math.Max(r0, (int)Math.Floor((Math.Min(az, Math.Min(bz, cz)) - LiftReach - g.MinZ) / Cell)); + int z1 = Math.Min(r1, (int)Math.Ceiling((Math.Max(az, Math.Max(bz, cz)) + LiftReach - g.MinZ) / Cell)); + for (int z = z0; z <= z1; z++) + { + double pz = g.MinZ + ((z + 0.5) * Cell); + for (int x = x0; x <= x1; x++) + { + double px = g.MinX + ((x + 0.5) * Cell); + double wa = (((bx - px) * (cz - pz)) - ((bz - pz) * (cx - px))) / area; + double wb = (((cx - px) * (az - pz)) - ((cz - pz) * (ax - px))) / area; + double wc = 1.0 - wa - wb; + bool inside = wa >= 0.0 && wb >= 0.0 && wc >= 0.0; + if (!inside && Math.Min(Segment(px, pz, ax, az, bx, bz), Math.Min(Segment(px, pz, bx, bz, cx, cz), Segment(px, pz, cx, cz, ax, az))) > LiftReach) + continue; + float y = (float)Math.Clamp((wa * t.A.Y) + (wb * t.B.Y) + (wc * t.C.Y), 0.0, top); + int i = (z * g.W) + x; + if (y > g.Lift[i]) + g.Lift[i] = y; + } + } + } + + // The distance from (px, pz) to the segment a..b. + private static double Segment(double px, double pz, double ax, double az, double bx, double bz) + { + double dx = bx - ax, dz = bz - az; + double len = (dx * dx) + (dz * dz); + double s = len > 0.0 ? Math.Clamp((((px - ax) * dx) + ((pz - az) * dz)) / len, 0.0, 1.0) : 0.0; + double ex = ax + (s * dx) - px, ez = az + (s * dz) - pz; + return Math.Sqrt((ex * ex) + (ez * ez)); + } + + // Narrows lo..hi to the px where one edge's weight can pass the test. The bound is worked in + // doubles and widened by two texels, far more than float rounding moves it. No texel the float + // test takes is cut. The weight times area is ex * pRel - ey * (px - vx). + private static void Clip(float ex, float ey, float pRel, float vx, float area, double reach, ref double lo, ref double hi) + { + double s = area > 0f ? 1.0 : -1.0; + double q = s * ey; + if (q == 0.0) + return; + double tol = 1e-4 * Math.Abs((double)area); + double bound = (tol + (s * ex * pRel)) / q; + double slack = (2.0 * Cell) + (1e-5 * (Math.Abs((double)ex * pRel) + (Math.Abs((double)ey) * reach) + tol) / Math.Abs(q)); + if (q > 0.0) + hi = Math.Min(hi, vx + bound + slack); + else + lo = Math.Max(lo, vx + bound - slack); + } + + private static void Corner(Grid g, int x, int z, int r0, int r1) + { + if (x >= 0 && x < g.W && z >= r0 && z <= r1) + g.Solid[(z * g.W) + x] = true; + } + + // Rows r0..r1 of both channels. The distance runs over the band plus its halo, then keeps its + // own rows. A chain reaching past the halo is longer than ShoreReach and saturates. + private static void Encode(Grid g, int r0, int r1, byte[] mask, byte[] lift) + { + int w = g.W; + int e0 = Math.Max(0, r0 - Halo), e1 = Math.Min(g.H - 1, r1 + Halo); + var dist = ShoreDistance(g, e0, e1); + for (int y = r0; y <= r1; y++) + { + for (int x = 0; x < w; x++) + { + int i = (y * w) + x; + mask[2 * i] = g.Sea[i] == 0 ? (byte)0 : g.Ramp[i] ? RampSea : (byte)255; + // The distance, not a height. The shader fades the swell and the look over ramps + // of their own, from the distance the linear filter interpolates between texels. + float d = dist[((y - e0) * w) + x]; + mask[(2 * i) + 1] = d >= ShoreReach ? (byte)255 : (byte)Math.Round(d / ShoreReach * 255f); + lift[i] = Channel(g.Lift[i] / OceanMask.RampTop); + } + } + } + + // Distance in metres to the nearest texel that is not open base sea, by a two-pass chamfer over + // rows e0..e1, indexed from row e0. + private static float[] ShoreDistance(Grid g, int e0, int e1) + { + int w = g.W, h = e1 - e0 + 1; + var dist = new float[w * h]; + const float big = 1e6f; + int off = e0 * w; + for (int i = 0; i < dist.Length; i++) + dist[i] = g.Sea[off + i] == 1 && !g.Solid[off + i] ? big : 0f; + const float d1 = Cell, d2 = Cell * 1.4142f; + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + int i = (y * w) + x; + float d = dist[i]; + if (d == 0f) + continue; + float e; + if (x > 0 && (e = dist[i - 1] + d1) < d) d = e; + if (y > 0 && (e = dist[i - w] + d1) < d) d = e; + if (x > 0 && y > 0 && (e = dist[i - w - 1] + d2) < d) d = e; + if (x < w - 1 && y > 0 && (e = dist[i - w + 1] + d2) < d) d = e; + dist[i] = d; + } + } + for (int y = h - 1; y >= 0; y--) + { + for (int x = w - 1; x >= 0; x--) + { + int i = (y * w) + x; + float d = dist[i]; + if (d == 0f) + continue; + float e; + if (x < w - 1 && (e = dist[i + 1] + d1) < d) d = e; + if (y < h - 1 && (e = dist[i + w] + d1) < d) d = e; + if (x < w - 1 && y < h - 1 && (e = dist[i + w + 1] + d2) < d) d = e; + if (x > 0 && y < h - 1 && (e = dist[i + w - 1] + d2) < d) d = e; + dist[i] = d; + } + } + return dist; + } + + /// One world-space triangle. On the base sheet it carries its corners' stored vertex + /// colours and its mesh instance's zone group. A ramp is a base triangle rising off sea level: + /// its texels take and its plane lifts the ocean. + public readonly record struct Tri(Vector3 A, Vector3 B, Vector3 C, Kind Kind, Color CA, Color CB, Color CC, byte Zone = 0, bool Ramp = false); + + /// The texel bytes of the mask, tint, zone group and lift, and each pass's milliseconds + /// over how many bands. The mask is RG8 (sea or , shore distance), the tint + /// RGB8 sRGB. The zone is R8, on surf-ring water. The lift is R8, the ocean's + /// height over a ramp over . + public sealed record Result(int Width, int Height, Vector2 Origin, byte[] Mask, byte[] Tint, + double FillMs, double EncodeMs, int Bands, byte[] Zones, byte[] Lift); + + // The coverage both passes share. Each band writes only its own rows. + private sealed class Grid + { + public Grid(int w, int h, float minX, float minZ) + { + W = w; + H = h; + MinX = minX; + MinZ = minZ; + Sea = new byte[w * h]; + Solid = new bool[w * h]; + Zone = new byte[w * h]; + Tinted = new bool[w * h]; + Ramp = new bool[w * h]; + Lift = new float[w * h]; + } + + public int W { get; } + + public int H { get; } + + public float MinX { get; } + + public float MinZ { get; } + + // 1 open base sea, 2 the surf ring's water. + public byte[] Sea { get; } + + public bool[] Solid { get; } + + // The zone group of the base sheet a texel shows, AnyZone on surf-ring water. + public byte[] Zone { get; } + + // Texels a base triangle gave a tint; the rest start as the mean. + public bool[] Tinted { get; } + + // Texels a ramp's base triangle covers. + public bool[] Ramp { get; } + + // The height in metres the ocean rises to over and around a ramp. + public float[] Lift { get; } + } +} diff --git a/CSVM/src/Effects/OceanMaskRaster.cs.uid b/CSVM/src/Effects/OceanMaskRaster.cs.uid new file mode 100644 index 000000000..2f5d99db4 --- /dev/null +++ b/CSVM/src/Effects/OceanMaskRaster.cs.uid @@ -0,0 +1 @@ +uid://cakbgbxh16nw8 diff --git a/CSVM/src/Effects/OceanMovers.cs b/CSVM/src/Effects/OceanMovers.cs new file mode 100644 index 000000000..7e0974923 --- /dev/null +++ b/CSVM/src/Effects/OceanMovers.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using CSVM.Mech3; +using Godot; + +namespace CSVM.Effects; + +/// +/// Which world nodes an animation carries across the sea, and which of those are hulls. A mover is +/// the target of a played event that changes where a node stands. That is an OBJECT_MOTION_FROM_TO +/// with a translate channel, an SI script, or an OBJECT_TRANSLATE_STATE in a sequence. A hull's +/// origin sits on sea level over sea and its meshes reach the waterline. The mask leaves a hull out, +/// and the ocean calms the waves around it wherever it goes. Debris, reset states and spins are not +/// movers: none carries a hull away from its load pose. +/// +internal static class OceanMovers +{ + /// A hull's origin sits within this many metres of sea level; a hoisted lifeboat's does + /// not. + public const float OriginBand = 3f; + + /// A hull's meshes reach within this many metres of sea level. + public const float WaterlineBand = 2f; + + /// The events of the definition's played sequences that move a node, in event order, + /// for to resolve. Its reset state is not played. + public static List MovingEvents(AnimDefinition def) + { + var events = new List(); + foreach (var seq in def.Sequences) + { + foreach (var ev in seq.Events) + { + if (Moves(ev)) + events.Add(ev); + } + } + return events; + } + + /// Whether an event changes where its target stands. An ALL_NAMES SI script does, for + /// each of its records. + public static bool Moves(AnimEvent ev) => ev.Kind switch + { + "ObjectMotionFromTo" => ev.Data.Has("translate") || ev.Data.Has("translate_delta"), + "ObjectMotionSiScript" or "ObjectTranslateState" or AnimDefinition.AllNamesKind => true, + _ => false, + }; + + /// Whether a triangle under a mover reaches the waterline. + public static bool ReachesWaterline(Vector3 a, Vector3 b, Vector3 c) => + Math.Min(a.Y, Math.Min(b.Y, c.Y)) <= WaterlineBand && Math.Max(a.Y, Math.Max(b.Y, c.Y)) >= -WaterlineBand; + + /// Whether a world X/Z point lies on the triangle's horizontal shadow, edges included. + public static bool Covers(Vector3 a, Vector3 b, Vector3 c, Vector2 p) + { + float d1 = Cross(a, b, p), d2 = Cross(b, c, p), d3 = Cross(c, a, p); + bool neg = d1 < 0f || d2 < 0f || d3 < 0f; + bool pos = d1 > 0f || d2 > 0f || d3 > 0f; + return !(neg && pos); + } + + /// Fills with the indices of the points + /// nearest , in their original order; all of them when there are no more + /// than that. The caller keeps the list, so the frame path allocates nothing while the slots + /// suffice. + public static void Nearest(IReadOnlyList points, Vector3 eye, int keep, List order) + { + order.Clear(); + for (int i = 0; i < points.Count; i++) + order.Add(i); + if (points.Count <= keep) + return; + order.Sort((x, y) => + { + int c = points[x].DistanceSquaredTo(eye).CompareTo(points[y].DistanceSquaredTo(eye)); + return c != 0 ? c : x.CompareTo(y); + }); + order.RemoveRange(keep, order.Count - keep); + order.Sort(); + } + + private static float Cross(Vector3 a, Vector3 b, Vector2 p) => + ((b.X - a.X) * (p.Y - a.Z)) - ((b.Z - a.Z) * (p.X - a.X)); +} diff --git a/CSVM/src/Effects/OceanMovers.cs.uid b/CSVM/src/Effects/OceanMovers.cs.uid new file mode 100644 index 000000000..9c748aaca --- /dev/null +++ b/CSVM/src/Effects/OceanMovers.cs.uid @@ -0,0 +1 @@ +uid://chvvk3ssm8ry3 diff --git a/CSVM/src/Effects/OceanSeas.cs b/CSVM/src/Effects/OceanSeas.cs new file mode 100644 index 000000000..e3087192b --- /dev/null +++ b/CSVM/src/Effects/OceanSeas.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CSVM.Effects; + +/// +/// The shipped per-chapter seas, CSVM/data/ocean_seas.json: one object per sea chapter +/// holding only the fields that differ from the defaults. A missing entry or +/// field takes the default; an unknown chapter or field is reported, never fatal. Keys beginning +/// with _ are prose. The ocean lab writes one chapter's entry through +/// , keeping every other key and its order. Shipped data, so a network peer +/// and a --det capture agree as long as both load the same file. +/// +public sealed class OceanSeas +{ + /// The committed file, read through Godot in an exported build, where it lives in the pck. + public const string DefaultPath = "res://data/ocean_seas.json"; + + /// Every chapter with a sea at y = 0, each with an entry in the file. C4 has only raised + /// lakes, which keep the flat glossy water. + public static readonly IReadOnlyList Chapters = new[] { "C1", "C1B", "C1C", "C2", "C2B", "C3", "C5" }; + + private static readonly JsonSerializerOptions Written = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + // Comments are skipped on every read, so a note added by hand breaks neither a load nor a Save. + private static readonly JsonDocumentOptions Reading = new() { CommentHandling = JsonCommentHandling.Skip }; + + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly List _warnings = new(); + + /// What the file got wrong, one line each. Unreadable JSON, an unknown chapter or field, a + /// value that is not a number and a value clamped into range each count. + public IReadOnlyList Warnings => _warnings; + + /// Whether a chapter names a sea in the file's list. + public static bool IsSeaChapter(string chapter) => + ((IList)Chapters).IndexOf(Canonical(chapter) ?? "") >= 0; + + /// Reads the seas, logging each warning. A missing or unreadable file draws every + /// chapter at the defaults, the ocean the constants draw. + public static OceanSeas Load() + { + OceanSeas seas; + if (!Godot.FileAccess.FileExists(DefaultPath)) + { + seas = new OceanSeas(); + seas._warnings.Add($"{DefaultPath} not found, every chapter takes the default sea"); + } + else + { + seas = Parse(Encoding.UTF8.GetString(Godot.FileAccess.GetFileAsBytes(DefaultPath))); + } + + foreach (var w in seas.Warnings) + Utils.Log.Warn("world", $"ocean seas: {w}"); + return seas; + } + + /// Parses the file's text with no file IO and no engine call, so the rules unit-test. + public static OceanSeas Parse(string json) + { + var seas = new OceanSeas(); + JsonNode? root; + try + { + root = JsonNode.Parse(json, documentOptions: Reading); + } + catch (JsonException ex) + { + seas._warnings.Add($"not readable JSON ({ex.Message}), every chapter takes the default sea"); + return seas; + } + + if (root is not JsonObject chapters) + { + seas._warnings.Add("the file is not a JSON object, every chapter takes the default sea"); + return seas; + } + + foreach (var (key, node) in chapters) + { + if (key.StartsWith('_')) + continue; + if (Canonical(key) is not { } chapter) + { + seas._warnings.Add($"'{key}' is not a sea chapter ({string.Join("/", Chapters)}), ignored"); + continue; + } + if (node is not JsonObject fields) + { + seas._warnings.Add($"{chapter} is not an object, it takes the default sea"); + continue; + } + seas._entries[chapter] = ReadEntry(chapter, fields, seas._warnings); + } + + return seas; + } + + /// The file's text with 's entry replaced by the fields of + /// that differ from the defaults, in order. + /// Every other key keeps its place and value; a chapter the file lacks is appended. Throws + /// when is not a JSON object, so a + /// Save never replaces a file it cannot read. + public static string WithEntry(string? existing, string chapter, SeaState sea) + { + string name = Canonical(chapter) ?? throw new ArgumentException($"'{chapter}' is not a sea chapter", nameof(chapter)); + JsonObject root; + if (string.IsNullOrWhiteSpace(existing)) + { + root = new JsonObject(); + } + else + { + try + { + root = JsonNode.Parse(existing, documentOptions: Reading) as JsonObject + ?? throw new InvalidDataException("the seas file is not a JSON object, so Save leaves it as it is"); + } + catch (JsonException ex) + { + throw new InvalidDataException($"the seas file is not readable JSON ({ex.Message}), so Save leaves it as it is", ex); + } + } + + var entry = new JsonObject(); + var written = sea.Written(); + foreach (var f in SeaState.Fields) + { + if (written.Differs(f)) + entry[f.Key] = JsonValue.Create(SeaState.Field.Rounded(f.Get(written))); + } + + // Rebuilt key by key, since a JsonObject cannot replace a member in its place. + var rebuilt = new JsonObject(); + bool placed = false; + foreach (var (key, node) in root) + { + if (Canonical(key) == name) + { + if (!placed) + rebuilt[name] = entry; + placed = true; + continue; + } + rebuilt[key] = node?.DeepClone(); + } + if (!placed) + rebuilt[name] = entry; + return rebuilt.ToJsonString(Written) + Environment.NewLine; + } + + /// Writes 's entry into the file at , + /// creating it when absent, and returns how many fields the entry holds. A file that is not a + /// JSON object is left untouched and thrown. + public static int Save(string diskPath, string chapter, SeaState sea) + { + string? existing = File.Exists(diskPath) ? File.ReadAllText(diskPath, Encoding.UTF8) : null; + File.WriteAllText(diskPath, WithEntry(existing, chapter, sea), new UTF8Encoding(false)); + int count = 0; + var written = sea.Written(); + foreach (var f in SeaState.Fields) + count += written.Differs(f) ? 1 : 0; + return count; + } + + /// The project's copy of the file on disk while the game runs from the source tree. Null in + /// an exported build, whose copy lives in the pck and cannot be written. + public static string? SourceTreePath() => + Godot.OS.HasFeature("editor") ? Godot.ProjectSettings.GlobalizePath(DefaultPath) : null; + + /// The chapter's saved sea, or the defaults when the file has no entry for it. + public SeaState For(string chapter) => + Canonical(chapter) is { } name && _entries.TryGetValue(name, out var sea) ? sea : SeaState.Default; + + // The file's spelling of a sea chapter, or null for any other key. + private static string? Canonical(string chapter) + { + foreach (var c in Chapters) + { + if (string.Equals(c, chapter, StringComparison.OrdinalIgnoreCase)) + return c; + } + + return null; + } + + private static SeaState ReadEntry(string chapter, JsonObject fields, List warnings) + { + var sea = SeaState.Default; + foreach (var (key, node) in fields) + { + if (key.StartsWith('_')) + continue; + if (SeaState.Find(key) is not { } field) + { + warnings.Add($"{chapter}: '{key}' is not a sea field, ignored"); + continue; + } + if (node is not JsonValue value || !value.TryGetValue(out double number) || !double.IsFinite(number)) + { + warnings.Add($"{chapter}: '{key}' is not a number, it takes the default"); + continue; + } + sea = field.With(sea, (float)number); + } + + var clamped = sea.Clamped(); + foreach (var f in SeaState.Fields) + { + if (f.Get(clamped) != f.Get(sea)) + warnings.Add($"{chapter}: '{f.Key}' {f.Format(f.Get(sea))} is held at {f.Format(f.Get(clamped))}"); + } + + return clamped; + } +} diff --git a/CSVM/src/Effects/OceanSeas.cs.uid b/CSVM/src/Effects/OceanSeas.cs.uid new file mode 100644 index 000000000..d70763201 --- /dev/null +++ b/CSVM/src/Effects/OceanSeas.cs.uid @@ -0,0 +1 @@ +uid://d8wkqi3x4ebl diff --git a/CSVM/src/Effects/OceanShader.cs b/CSVM/src/Effects/OceanShader.cs new file mode 100644 index 000000000..a7688848e --- /dev/null +++ b/CSVM/src/Effects/OceanShader.cs @@ -0,0 +1,587 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using CSVM.Mech3; +using CSVM.Utils; +using Godot; + +namespace CSVM.Effects; + +/// +/// The wave ocean's shader text, generated from one sea () with no engine object +/// touched. The swell and detail tables, the foam and the coast ramps are written as literals. The +/// defaults write the constants' text, held byte for byte by OceanShaderTests, so no golden +/// moves until a sea is saved. Every rate is rounded to whole cycles per csky_time wrap, so +/// the hourly rollover lands on an identical frame at any setting. +/// +public static class OceanShader +{ + /// The ship calm zones the shader carries, nearest the eye first. + public const int MaxShips = 16; + + // TUNE. The fog amount by which the ocean shades wholly as the flat sheet. Low, so fog thick + // enough to see covers no wave the sheet would not show. + private const float FogSheetAt = 0.25f; + + // TUNE. The sea-state field that keeps the fixed waves from meeting on a lattice. Each wave reads + // two noise fields along its own direction: one scales its height, one warps its phase. How far + // the warp bends a crest is the sea's (SeaState.BendMetres, BendRadians). The warp's slope is + // part of each wave's local wavelength, which the fades and the normals read. The field holds + // still in world space, so the csky_time wrap lands on an identical frame. + private const float GroupScale = 500f; + private const float GroupGain = 0.8f; + private const float PhaseScaleCoarse = 360f; + private const float PhaseScaleFine = 90f; + private const float PhaseFineShare = 0.5f; + + // The longest a warped wave counts as, in its own lengths, when the fades read it. The bound + // keeps the tables' longest-first order valid for every later wave's local length. + private const float WarpStretchMax = 1.5f; + + // TUNE. A finer field for the short waves, whose crests the coarse one leaves as a straight + // grain. A wave feels it fully at FineFull metres and shorter, not at all from FineFrom up, so + // the long swell keeps its shape. + private const float FineGroupScale = 40f; + private const float FinePhaseScale = 28f; + private const float FineGain = 1.2f; + private const float FineMetres = 5f; + private const float FineFrom = 40f; + private const float FineFull = 10f; + + // TUNE. The cells a detail layer's wavelength is taken as for the footprint fade. Gradient + // noise puts most of its energy near two cells. + private const float DetailWaveCells = 2f; + + // The cells along a detail layer's drift axis after which its noise repeats, so the drift + // wraps with csky_time. At the shortest cell it is still hundreds of metres. + private const int DetailPeriod = 1024; + + // TUNE. Pixels per wavelength over which a wave fades into the fragment normals. A wave drawn + // at a few pixels per wavelength reads as a straight grain, not as water. + private const float FootprintFadeFrom = 4f; + private const float FootprintFadeFull = 9f; + + // TUNE. The foam gate's width in standard deviations of the swell's Jacobian, above the sea's + // threshold. Wide, so a whitecap grows and fades as its crest passes instead of popping. + private const float FoamGateWidth = 1f; + + // TUNE. How far the patch field moves a crest's threshold, in the same standard deviations. + private const float FoamPatchShift = 1.2f; + + // TUNE. The foam's own shape. A breakup splits a crest's foam into whitecaps. Up close each + // whitecap is a sheet with round holes FoamCellFine apart, longer along the wind; a coarse + // noise thins it and warps the holes. All of it rides the water (the grid parameter), so a + // passing crest uncovers it instead of dragging it along. + private const float FoamBreakupCell = 12f; + private const float FoamCellCoarse = 3.2f; + private const float FoamCellFine = 1.4f; + private const float FoamStretch = 1.8f; + + // TUNE. Foam's roughness, which it takes in the share it covers, so it reflects no sky. + private const float FoamRoughness = 0.6f; + + // TUNE. The swell the vertex stage displaces: wavelengths of tens of metres, a crest of about + // 1-2 m where several meet. Angles are offsets from the wind. The lengths step by about 1.3 + // with no common ratio, so no two waves dominate and cross on a visible lattice. + private static readonly Wave[] Swell = + { + new(0f, 152f, 0.04f), new(31f, 117f, 0.042f), new(-24f, 93f, 0.045f), new(55f, 71f, 0.047f), + new(-47f, 57f, 0.05f), new(14f, 44f, 0.052f), new(-71f, 34f, 0.052f), new(38f, 26f, 0.06f), + }; + + // TUNE. The fine detail the fragment normals add on top of the swell: gradient-noise layers, each + // drifting along its own direction. Noise has no fixed crests, so crossing layers print no weave. + // Cell in metres, longest first; speed in m/s, rounded so the csky_time wrap lands on a whole + // DetailPeriod of cells. + private static readonly Detail[] DetailLayers = + { + new(-38f, 6f, 2.2f), new(57f, 3.4f, 1.6f), new(-71f, 1.9f, 1.2f), new(14f, 1.05f, 0.9f), + new(-20f, 0.58f, 0.6f), + }; + + // TUNE. The foam's patch field, which decides where whitecaps can form: two gradient-noise + // octaves drifting with the wind. Cell in metres, weight, drift in m/s, rounded so the + // csky_time wrap lands on a whole period of cells. + private static readonly FoamPatch[] FoamPatches = { new(230f, 0.65f, 1.5f), new(71f, 0.35f, 1.5f) }; + + /// The ocean's shader text for , clamped first, drawn at priority + /// . A sheet gets the zone gate. + public static string Code(SeaState sea, int level, bool zoned) => ShaderCode(sea.Clamped(), level, zoned); + + /// The cycles a swell wave of metres runs per csky_time + /// wrap: deep-water omega rounded to a whole number, at least one. + public static float SwellCyclesPerWrap(float length) + { + float omega = Mathf.Sqrt(9.81f * (Mathf.Tau / length)); + return Mathf.Max(1f, Mathf.Round(omega * (float)ShaderTime.RolloverSecs / Mathf.Tau)); + } + + /// The whole noise periods a detail layer of metres drifts per + /// csky_time wrap at m/s: one at least, unless held still. + public static double DetailTurnsPerWrap(float cell, float speed) => + Math.Max(speed > 0f ? 1.0 : 0.0, Math.Round(speed * ShaderTime.RolloverSecs / (DetailPeriod * cell))); + + /// The whole periods of cells a foam patch octave of metres + /// drifts per csky_time wrap at m/s, at least one. + public static float PatchPeriodsPerWrap(float cell, float speed) => + (float)Math.Max(1.0, Math.Round(speed * ShaderTime.RolloverSecs / cell)); + + /// The swell table's wavelengths as the sea scales them, longest first. + public static IEnumerable SwellLengths(SeaState sea) + { + foreach (var w in Swell) + yield return w.Length * sea.Length; + } + + private static string Literal(float v) => v.ToString("0.0#####", CultureInfo.InvariantCulture); + + // vec4(drift dir.x, dir.z, 1 / cell, drift in cells per second) per detail layer, and vec2 + // grouping weights, a golden angle on per layer. The drift is a whole number of DetailPeriods + // per csky_time wrap, so the hourly rollover lands on an identical frame. + private static void EmitDetail(StringBuilder sb, SeaState sea) + { + var l = new List(); + var g = new List(); + for (int i = 0; i < DetailLayers.Length; i++) + { + var d = DetailLayers[i]; + if (i > 0 && d.Cell >= DetailLayers[i - 1].Cell) + throw new InvalidOperationException("ocean: the detail table must run longest first; the fragment's fade ends its sum on the first faded layer"); + float ang = Mathf.DegToRad(sea.WindDeg + d.AngleDeg); + double turns = DetailTurnsPerWrap(d.Cell, d.Speed * sea.DetailDrift); + float cellsPerSec = (float)(turns * DetailPeriod / ShaderTime.RolloverSecs); + l.Add($"vec4({Literal(Mathf.Cos(ang))}, {Literal(Mathf.Sin(ang))}, {Literal(1f / d.Cell)}, {Literal(cellsPerSec)})"); + float u = Mathf.DegToRad(137.50776f * (i + Swell.Length)); + g.Add($"vec2({Literal(GroupGain * Mathf.Cos(u))}, {Literal(GroupGain * Mathf.Sin(u))})"); + } + sb.AppendLine($"const vec4 DETAIL_L[{l.Count}] = vec4[{l.Count}]({string.Join(", ", l)});"); + sb.AppendLine($"const vec2 DETAIL_G[{g.Count}] = vec2[{g.Count}]({string.Join(", ", g)});"); + } + + // The patch field's octaves as shader terms. Each drifts one whole period of cells per csky_time + // wrap, so the hourly rollover lands on an identical field. + private static string FoamPatchTerms(SeaState sea) + { + var sb = new StringBuilder(); + foreach (var o in FoamPatches) + { + float cell = o.Cell * sea.FoamPatch; + float period = PatchPeriodsPerWrap(cell, o.Speed); + string perSec = $"({Literal(period)} / {Literal((float)ShaderTime.RolloverSecs)})"; + sb.Append(CultureInfo.InvariantCulture, $" + {Literal(o.Weight)} * foam_noise(pw * {Literal(1f / cell)} - vec2(csky_time * {perSec}, 0.0), {Literal(period)})"); + } + return sb.ToString(); + } + + // vec4(dir.x, dir.z, k, A) and vec2(Q, omega) per wave. Omega is rounded to a whole number of + // cycles per csky_time wrap, so the hourly rollover lands on an identical frame. + // Also vec4 _MOD per wave: the height field's weights, then the phase field's. Each wave's weights + // turn a golden angle on from the last, so no two waves follow the field alike. The _FINE + // weights do the same on the fine field, scaled by how short the wave is. + private static void EmitWaves(StringBuilder sb, string name, Wave[] waves, SeaState sea) + { + var a = new List(); + var b = new List(); + var m = new List(); + var fine = new List(); + for (int i = 1; i < waves.Length; i++) + { + if (waves[i].Length >= waves[i - 1].Length) + throw new InvalidOperationException($"ocean: the {name} table must run longest first; the fragment's fade ends its sum on the first faded wave"); + } + foreach (var w in waves) + { + float length = w.Length * sea.Length; + float u = Mathf.DegToRad(137.50776f * m.Count); + float rad = Mathf.Min(sea.BendRadians, Mathf.Tau / length * sea.BendMetres); + m.Add($"vec4({Literal(GroupGain * Mathf.Cos(u))}, {Literal(GroupGain * Mathf.Sin(u))}, {Literal(rad * Mathf.Sin(u + 1f))}, {Literal(rad * Mathf.Cos(u + 1f))})"); + float s = Mathf.Clamp((FineFrom - length) / (FineFrom - FineFull), 0f, 1f); + float fineRad = s * Mathf.Min(sea.BendRadians, Mathf.Tau / length * FineMetres); + float v = u + 2f; + fine.Add($"vec4({Literal(s * FineGain * Mathf.Cos(v))}, {Literal(s * FineGain * Mathf.Sin(v))}, {Literal(fineRad * Mathf.Sin(v + 1f))}, {Literal(fineRad * Mathf.Cos(v + 1f))})"); + float ang = Mathf.DegToRad(sea.WindDeg + w.AngleDeg); + float k = Mathf.Tau / length; + float amp = w.Steepness / k; + float omega = Mathf.Tau * SwellCyclesPerWrap(length) / (float)ShaderTime.RolloverSecs; + float q = sea.Sharpness / (k * amp * waves.Length); + a.Add($"vec4({Literal(Mathf.Cos(ang))}, {Literal(Mathf.Sin(ang))}, {Literal(k)}, {Literal(amp)})"); + b.Add($"vec2({Literal(q)}, {Literal(omega)})"); + } + sb.AppendLine($"const vec4 {name}_DKA[{waves.Length}] = vec4[{waves.Length}]({string.Join(", ", a)});"); + sb.AppendLine($"const vec2 {name}_QW[{waves.Length}] = vec2[{waves.Length}]({string.Join(", ", b)});"); + sb.AppendLine($"const vec4 {name}_MOD[{waves.Length}] = vec4[{waves.Length}]({string.Join(", ", m)});"); + sb.AppendLine($"const vec4 {name}_FINE[{waves.Length}] = vec4[{waves.Length}]({string.Join(", ", fine)});"); + } + + // The view-space scale that draws the ocean at priority level, in the bias shader's own step + // (SceneBuilder.DepthBiasPerLevel), spelled as a shader literal. + private static string DepthScale(int level) => + (1.0 - (level * (double)SceneBuilder.DepthBiasPerLevel)).ToString("0.0######", CultureInfo.InvariantCulture); + + // The open sea's colour: the texture's mean, times the sea's tint when it has one. An untinted + // sea writes no tint term, so its text matches the defaults'. + private static string OpenSeaColour(SeaState sea) => + "textureLod(albedo_tex, vec2(0.5), 16.0).rgb" + + (sea.Tinted ? $" * vec3({Literal(sea.TintR)}, {Literal(sea.TintG)}, {Literal(sea.TintB)})" : ""); + + private static string ShaderCode(SeaState sea, int level, bool zoned) + { + // The Jacobian's standard deviation below 1 at full swell. Each wave's crest bunches the + // surface by Q k A = Sharpness / waves, so the share holds with the wave count. + float jacSigma = sea.Sharpness / Mathf.Sqrt(2f * Swell.Length); + float foamFull = sea.FoamThreshold + FoamGateWidth; + var sb = new StringBuilder(); + sb.AppendLine("shader_type spatial;"); + sb.AppendLine("render_mode skip_vertex_transform, cull_disabled;"); + sb.AppendLine(SceneBuilder.TimeInclude); + sb.AppendLine(SceneBuilder.AtmosphereInclude); + sb.AppendLine(SceneBuilder.MipBiasInclude); + sb.AppendLine("uniform sampler2D mask_tex : filter_linear, repeat_disable;"); + sb.AppendLine("uniform vec4 mask_rect;"); + sb.AppendLine("uniform sampler2D albedo_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable;"); + sb.AppendLine("uniform float tile_m = 32.0;"); + sb.AppendLine("uniform sampler2D tint_tex : source_color, filter_linear, repeat_disable;"); + sb.AppendLine("uniform sampler2D lift_tex : hint_default_black, filter_linear, repeat_disable;"); + sb.AppendLine("uniform float rough_near = 0.2;"); + sb.AppendLine("uniform float rough_far = 0.3;"); + sb.AppendLine("uniform float wave_scale = 1.0;"); + sb.AppendLine("uniform float foam_strength = 0.12;"); + // Per ship zone: centre X/Z and heading axis, then the half length, half width, margin, fade. + sb.AppendLine($"uniform vec4 calm_axes[{MaxShips}];"); + sb.AppendLine($"uniform vec4 calm_sizes[{MaxShips}];"); + // A sheet split over zone layers gets one grid per group, each keeping only its own texels. + if (zoned) + { + sb.AppendLine("uniform sampler2D zone_tex : filter_nearest, repeat_disable;"); + sb.AppendLine("instance uniform float zone_index = 0.0;"); + } + EmitWaves(sb, "SWELL", Swell, sea); + EmitDetail(sb, sea); + sb.AppendLine($"const float FOAM_JAC_SIGMA = {Literal(jacSigma)};"); + sb.AppendLine("varying vec2 v_param;"); + sb.AppendLine(@" +vec2 ocean_mask(vec2 p) { + return textureLod(mask_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).rg; +} + +// The swell height and the look left at the mask's shore distance g. +float shore_swell(float g) { + return smoothstep(" + Literal(sea.SwellFrom / OceanMaskRaster.ShoreReach) + @", " + Literal(sea.SwellFull / OceanMaskRaster.ShoreReach) + @", g); +} + +float shore_look(float g) { + return smoothstep(" + Literal(sea.LookFrom / OceanMaskRaster.ShoreReach) + @", " + Literal(sea.LookFull / OceanMaskRaster.ShoreReach) + @", g); +} + +// Two hashes in [0, 1] per integer cell, without sin, so it costs little per wave sum. +vec2 sea_hash(vec2 c) { + vec3 p3 = fract(c.xyx * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +// Two smooth value-noise channels in [-1, 1]. +vec2 sea_noise(vec2 p) { + vec2 c = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(sea_hash(c), sea_hash(c + vec2(1.0, 0.0)), f.x), + mix(sea_hash(c + vec2(0.0, 1.0)), sea_hash(c + vec2(1.0, 1.0)), f.x), f.y) * 2.0 - 1.0; +} + +// sea_noise with its slope: d's columns are the two channels' change along x and along y. +vec2 sea_noise_d(vec2 p, out mat2 d) { + vec2 c = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + vec2 du = 6.0 * f * (1.0 - f); + vec2 a = sea_hash(c); + vec2 k1 = sea_hash(c + vec2(1.0, 0.0)) - a; + vec2 k2 = sea_hash(c + vec2(0.0, 1.0)) - a; + vec2 k3 = sea_hash(c + vec2(1.0, 1.0)) - a - k1 - k2; + d = mat2(2.0 * du.x * (k1 + k3 * u.y), 2.0 * du.y * (k2 + k3 * u.x)); + return (a + k1 * u.x + k2 * u.y + k3 * (u.x * u.y)) * 2.0 - 1.0; +} + +// The grouping channels at p. Each field is turned off the grid's axes so the noise cells do not +// line up with each other. +vec2 sea_group(vec2 p) { + return sea_noise(mat2(vec2(0.8, 0.6), vec2(-0.6, 0.8)) * p * " + Literal(1f / GroupScale) + @"); +} + +// The phase (warp) channels at p in xy, and its fine octave alone in zw. The out matrix is the +// slope of xy per metre. A wave's local wavenumber is its own plus its phase weights times it. +vec4 sea_warp(vec2 p, out mat2 d) { + const mat2 rc = mat2(vec2(0.6, -0.8), vec2(0.8, 0.6)); + const mat2 rf = mat2(vec2(0.28, 0.96), vec2(-0.96, 0.28)); + mat2 dc; + mat2 df; + vec2 c = sea_noise_d(rc * p * " + Literal(1f / PhaseScaleCoarse) + @" + vec2(17.0, 5.0), dc); + vec2 f = sea_noise_d(rf * p * " + Literal(1f / PhaseScaleFine) + @" + vec2(-9.0, 31.0), df); + d = dc * rc * " + Literal((1f - PhaseFineShare) / PhaseScaleCoarse) + @" + df * rf * " + Literal(PhaseFineShare / PhaseScaleFine) + @"; + return vec4(c * " + Literal(1f - PhaseFineShare) + @" + f * " + Literal(PhaseFineShare) + @", f); +} + +// A wave's local wavenumber vector: its own plus the warp's slope along its phase weights. Its +// length is capped at WarpStretchMax times the wave's own, so the fades keep their order. +vec2 sea_wavevector(vec4 w, vec4 coarse_w, mat2 d) { + vec2 k = w.z * w.xy + coarse_w.zw * d; + return k * max(1.0, w.z / (" + Literal(WarpStretchMax) + @" * max(length(k), 1e-4))); +} + +// The fine field at p: xy its grouping channels, zw its phase channels. +vec4 sea_fine(vec2 p) { + vec2 g = sea_noise(mat2(vec2(0.96, -0.28), vec2(0.28, 0.96)) * p * " + Literal(1f / FineGroupScale) + @" + vec2(41.0, -23.0)); + vec2 f = sea_noise(mat2(vec2(-0.6, 0.8), vec2(-0.8, -0.6)) * p * " + Literal(1f / FinePhaseScale) + @" + vec2(-57.0, 13.0)); + return vec4(g, f); +} + +// Two gradients in [-1, 1] per integer cell. The cell's x is taken modulo DetailPeriod, so a +// layer's noise repeats along its drift axis and its drift wraps with csky_time. +vec2 detail_hash(vec2 c) { + return sea_hash(vec2(mod(c.x, " + DetailPeriod.ToString(CultureInfo.InvariantCulture) + @".0), c.y)) * 2.0 - 1.0; +} + +// Gradient noise at q: x the value, yz its slope along q's axes. +vec3 detail_noise(vec2 q) { + vec2 i = floor(q); + vec2 f = fract(q); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 du = 30.0 * f * f * (f * (f - 2.0) + 1.0); + vec2 ga = detail_hash(i); + vec2 gb = detail_hash(i + vec2(1.0, 0.0)); + vec2 gc = detail_hash(i + vec2(0.0, 1.0)); + vec2 gd = detail_hash(i + vec2(1.0, 1.0)); + float va = dot(ga, f); + float vb = dot(gb, f - vec2(1.0, 0.0)); + float vc = dot(gc, f - vec2(0.0, 1.0)); + float vd = dot(gd, f - vec2(1.0, 1.0)); + float k = va - vb - vc + vd; + return vec3(va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * k, + ga + u.x * (gb - ga) + u.y * (gc - ga) + u.x * u.y * (ga - gb - gc + gd) + du * (u.yx * k + vec2(vb, vc) - va)); +} + +// Gradient noise at q, about -0.7 to 0.7, smooth across its cells. The cell's x repeats every +// period cells. The half cell keeps the modulo exact, so no corner hashes differently in two cells. +float foam_noise(vec2 q, float period) { + vec2 i = floor(q); + vec2 f = fract(q); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + vec2 x = vec2(i.x, i.x + 1.0); + x -= period * floor((x + 0.5) / period); + float a = dot(sea_hash(vec2(x.x, i.y)) * 2.0 - 1.0, f); + float b = dot(sea_hash(vec2(x.y, i.y)) * 2.0 - 1.0, f - vec2(1.0, 0.0)); + float c = dot(sea_hash(vec2(x.x, i.y + 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0)); + float d = dot(sea_hash(vec2(x.y, i.y + 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// The holes in a sheet of foam: one round hole per jittered cell, each its own size. Returns the +// distance from q to the nearest hole's centre in that hole's radii, so under 1 inside a hole. +float foam_holes(vec2 q) { + vec2 i = floor(q); + vec2 f = fract(q); + float s = 9.0; + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 o = vec2(float(x), float(y)); + vec2 h = sea_hash(i + o); + vec2 r = o + h * 0.8 + 0.1 - f; + s = min(s, length(r) / (0.35 + 0.4 * fract(h.x * 7.31 + h.y * 3.17))); + } + } + return s; +} + +// Where whitecaps can form, about 0 to 1, at pw (metres along and across the wind). +float foam_patches(vec2 pw) { + return 0.5" + FoamPatchTerms(sea) + @"; +} + +// One wave's height factor and phase shift from its weights on both fields. +vec2 sea_mod(vec4 coarse_w, vec4 fine_w, vec2 group, vec2 warp, vec4 fine) { + return vec2(clamp(1.0 + dot(coarse_w.xy, group) + dot(fine_w.xy, fine.xy), 0.0, 1.8), + dot(coarse_w.zw, warp) + dot(fine_w.zw, fine.zw)); +} + +// OceanCalmZone.Distance and Calm, per ship: flat within the margin of the box, full height a fade +// further out. +float ship_calm(vec2 p) { + float a = 1.0; + for (int i = 0; i < " + MaxShips.ToString(CultureInfo.InvariantCulture) + @"; i++) { + vec2 q = p - calm_axes[i].xy; + vec2 u = calm_axes[i].zw; + vec2 e = max(abs(vec2(dot(q, u), dot(q, vec2(-u.y, u.x)))) - calm_sizes[i].xy, vec2(0.0)); + a = min(a, smoothstep(calm_sizes[i].z, calm_sizes[i].z + calm_sizes[i].w, length(e))); + } + return a; +} + +void vertex() { + // A spyglass disc shows the flat sea. Every vertex goes to one point behind the eye, so the + // clipper drops each triangle before a fragment and no wave is summed. + if (" + SceneBuilder.FlatSeaEye + @") { + VERTEX = vec3(0.0, 0.0, 1.0); + v_param = vec2(0.0); + } else { + vec2 p = VERTEX.xz + CAMERA_POSITION_WORLD.xz; + float spacing = max(UV.x, 0.01); + float amp = shore_swell(ocean_mask(p).g) * ship_calm(p) * wave_scale; + vec3 disp = vec3(0.0); + vec2 group = sea_group(p); + mat2 dwarp; + vec4 warp = sea_warp(p, dwarp); + vec4 fine = sea_fine(p); + for (int i = 0; i < SWELL_DKA.length(); i++) { + vec4 w = SWELL_DKA[i]; + float len = 6.2831853 / length(sea_wavevector(w, SWELL_MOD[i], dwarp)); + vec2 sm = sea_mod(SWELL_MOD[i], SWELL_FINE[i], group, warp.xy, fine); + // Full height at eight grid steps per local wavelength, none at four. A wave the grid + // cannot carry would crawl as the grid follows the eye. + float g = amp * clamp(len / spacing * 0.25 - 1.0, 0.0, 1.0) * sm.x; + float th = w.z * dot(w.xy, p) - SWELL_QW[i].y * csky_time + sm.y; + disp.xz += w.xy * (SWELL_QW[i].x * w.w * g * cos(th)); + disp.y += w.w * g * sin(th); + } + v_param = p; + // Over a harbour ramp the sea rises with the sheet it replaces, up to the quay it meets. + float lift = textureLod(lift_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).r * " + Literal(OceanMask.RampTop) + @"; + vec3 world = vec3(p.x + disp.x, disp.y + lift, p.y + disp.z); + VERTEX = (VIEW_MATRIX * vec4(world, 1.0)).xyz; + // A priority level below the lowest base sheet. Every coplanar layer over or beside it + // stays on top, the surf ring and C5's fog-gradient passes included. + VERTEX *= " + DepthScale(level) + @"; + NORMAL = (VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz; + } +} + +void fragment() { + vec2 p = v_param; + vec2 m = ocean_mask(p); + vec3 world = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; + // One-sided like the sheet it replaces: an eye below the surface sees through it. + if (m.r < " + Literal(OceanMask.SeaThreshold) + @" || CAMERA_POSITION_WORLD.y < world.y) { + discard; + }" + (zoned ? @" + float zone = textureLod(zone_tex, (p - mask_rect.xy) * mask_rect.zw, 0.0).r * 255.0; + if (zone < " + Literal(OceanMaskRaster.AnyZone - 0.5f) + @" && abs(zone - zone_index) > 0.5) { + discard; + }" : "") + @" + // A ship's zone and the shore's swell ramp flatten only the geometry. The normals keep the + // swell and the fine detail, so the water there shades like the open sea. Only the short look + // ramp below calms them. A calm patch would read as an artefact. + float look = shore_look(m.g); + float amp = wave_scale; + vec2 fw = fwidth(p); + float footprint = max(max(fw.x, fw.y), 0.001); + vec3 n = vec3(0.0, 1.0, 0.0); + // The horizontal displacement's slope (columns: along x, along z), for the foam's Jacobian. + // Summed from the fragment's own waves, not the grid's, so the foam reads the same at every + // distance the waves are drawn. + vec2 bx = vec2(0.0); + vec2 bz = vec2(0.0); + // The warp's slope is in each wave's normal, as the vertex stage's fade reads it. The grouping + // and fine fields' slopes are left out: they bend a crest by a few per cent at most. + vec2 group = sea_group(p); + mat2 dwarp; + vec4 warp = sea_warp(p, dwarp); + vec4 fine = sea_fine(p); + // The tables run longest first (EmitWaves checks), and no local length passes WarpStretchMax + // times the wave's own. Once that bound fades out, every later wave fades too. + for (int i = 0; i < SWELL_DKA.length(); i++) { + vec4 w = SWELL_DKA[i]; + float len = 6.2831853 / w.z; + if (len * " + Literal(WarpStretchMax) + @" <= footprint * " + Literal(FootprintFadeFrom) + @") { + break; + } + vec2 kv = sea_wavevector(w, SWELL_MOD[i], dwarp); + float fade = smoothstep(" + Literal(FootprintFadeFrom) + @", " + Literal(FootprintFadeFull) + @", 6.2831853 / length(kv) / footprint); + vec2 sm = sea_mod(SWELL_MOD[i], SWELL_FINE[i], group, warp.xy, fine); + float f = amp * fade * sm.x; + float th = w.z * dot(w.xy, p) - SWELL_QW[i].y * csky_time + sm.y; + float ka = w.z * w.w * f; + float s = sin(th); + n.xz -= kv * (w.w * f * cos(th)); + n.y -= SWELL_QW[i].x * ka * s; + float qs = SWELL_QW[i].x * w.w * f * s; + bx += w.xy * (kv.x * qs); + bz += w.xy * (kv.y * qs); + } + // The detail layers run longest first (EmitDetail checks), so the first faded layer ends the sum. + // A layer's height is DetailSlope times its cell times the noise, the same slope at every cell + // size. The sea-state grouping varies it by region. + for (int i = 0; i < DETAIL_L.length(); i++) { + vec4 l = DETAIL_L[i]; + float fade = smoothstep(" + Literal(FootprintFadeFrom) + @", " + Literal(FootprintFadeFull) + @", " + Literal(DetailWaveCells) + @" / l.z / footprint); + if (fade <= 0.0) { + break; + } + vec2 side = vec2(-l.y, l.x); + vec2 q = vec2(dot(p, l.xy), dot(p, side)) * l.z - vec2(l.w * csky_time, 0.0); + vec3 nd = detail_noise(q); + float f = amp * fade * " + Literal(sea.DetailSlope) + @" * clamp(1.0 + dot(DETAIL_G[i], group), 0.2, 1.8); + n.xz -= (l.xy * nd.y + side * nd.z) * f; + } + float fog_amt = csky_fog_amount(world, CAMERA_POSITION_WORLD); + // How fully this fragment shades as the flat sheet does. Wholly at the shore, so no edge shows + // against a coplanar coast tile. Rising with the fog, so the far sea fogs as the sheet does + // instead of showing darker, wavier water through it. + float sheet = max(1.0 - look, smoothstep(0.0, " + Literal(FogSheetAt) + @", fog_amt)); + n = normalize(mix(normalize(n), vec3(0.0, 1.0, 0.0), sheet)); + NORMAL = normalize((VIEW_MATRIX * vec4(n, 0.0)).xyz); + float dist = distance(world.xz, CAMERA_POSITION_WORLD.xz); + vec3 tint = texture(tint_tex, (p - mask_rect.xy) * mask_rect.zw).rgb; + // The open sea takes the texture's mean and the light alone varies it: a tiled texture reads as + // a lattice from the air. The last mip is the mean the GPU samples. At sheet = 1 the texture is + // the sheet's own mapping, so no seam shows where the ocean meets a coast tile. + vec3 tex_mean = " + OpenSeaColour(sea) + @"; + vec3 col = mix(tex_mean, csky_sample_albedo(albedo_tex, p / tile_m).rgb, sheet) * tint; + // Foam forms where the swell bunches the surface, its Jacobian below 1, which rides each crest. + // A drifting patch field decides where whitecaps can form and moves each crest's threshold. + float bunch = (1.0 - ((1.0 - bx.x) * (1.0 - bz.y) - bz.x * bx.y)) / FOAM_JAC_SIGMA; + float foam = 0.0; + if (bunch > " + Literal(sea.FoamThreshold - (0.75f * FoamPatchShift)) + @") { + vec2 wind = SWELL_DKA[0].xy; + vec2 pw = vec2(dot(p, wind), dot(p, vec2(-wind.y, wind.x))); + float patches = foam_patches(pw); + float crest = smoothstep(" + Literal(sea.FoamThreshold) + @", " + Literal(foamFull) + @", bunch + (patches - 0.5) * " + Literal(FoamPatchShift) + @") + * smoothstep(0.5, 0.75, patches); + crest *= smoothstep(0.42, 0.62, 0.5 + foam_noise(pw * " + Literal(1f / FoamBreakupCell) + @", 65536.0)); + foam = crest; + // Up close a whitecap breaks into patches whose mean is the crest's foam. They fade in with + // the footprint as the detail layers do; their edge widens with it, so none shimmers. + float near = smoothstep(" + Literal(FootprintFadeFrom) + @", " + Literal(FootprintFadeFull) + @", " + Literal(FoamCellFine) + @" / footprint); + if (near > 0.0) { + vec2 q = pw * vec2(" + Literal(1f / (FoamCellCoarse * FoamStretch)) + @", " + Literal(1f / FoamCellCoarse) + @"); + vec2 warp = vec2(foam_noise(q, 65536.0), foam_noise(q + vec2(17.0, 9.0), 65536.0)); + float cover = " + Literal(sea.FoamCover) + @" * crest * clamp(1.0 + 2.5 * warp.x, 0.0, 2.0); + float s = foam_holes(q * " + Literal(FoamCellCoarse / FoamCellFine) + @" + warp * 1.2 + vec2(31.0, -17.0)); + float hole = 1.3 - 1.6 * cover; + float edge = max(0.04, " + Literal(2f / FoamCellFine) + @" * footprint); + float lump = smoothstep(hole - edge, hole + edge + 0.35, s) * min(cover * 20.0, 1.0); + foam = mix(crest, lump * " + Literal(1f / sea.FoamCover) + @", near); + } + } + float foam_mix = min(foam * foam_strength, 1.0) * (1.0 - sheet); + col = mix(col, vec3(0.6, 0.65, 0.68), foam_mix); + ALBEDO = col; + METALLIC = 0.0; + SPECULAR = " + Literal(SceneBuilder.WaterSpecular) + @"; + // Lost slope detail turns into roughness, so the far sea keeps a glossy sheen, not a mirror. + // Foam is matte where it covers the water. + ROUGHNESS = mix(mix(mix(rough_near, rough_far, smoothstep(150.0, 4000.0, dist)), " + Literal(SceneBuilder.WaterRoughness) + @", sheet), " + Literal(FoamRoughness) + @", foam_mix); + FOG = vec4(csky_fog_color_at(CAMERA_POSITION_WORLD), fog_amt); +}"); + return sb.ToString(); + } + + // One Gerstner component: direction, wavelength in metres, steepness k*A. + private readonly record struct Wave(float AngleDeg, float Length, float Steepness); + + // One fine-detail noise layer: drift direction off the wind, cell size in metres, drift in m/s. + private readonly record struct Detail(float AngleDeg, float Cell, float Speed); + + // One octave of the foam's patch field: cell size in metres, weight, drift along the wind in m/s. + private readonly record struct FoamPatch(float Cell, float Weight, float Speed); +} diff --git a/CSVM/src/Effects/OceanShader.cs.uid b/CSVM/src/Effects/OceanShader.cs.uid new file mode 100644 index 000000000..3814c05c8 --- /dev/null +++ b/CSVM/src/Effects/OceanShader.cs.uid @@ -0,0 +1 @@ +uid://b6be2fnrbs40w diff --git a/CSVM/src/Effects/SeaState.cs b/CSVM/src/Effects/SeaState.cs new file mode 100644 index 000000000..566f8c37a --- /dev/null +++ b/CSVM/src/Effects/SeaState.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace CSVM.Effects; + +/// +/// One chapter's sea on the wave ocean: every value the ocean lab tunes, each defaulting to the +/// ocean's tune. The defaults generate the constants' shader text. The field table +/// () names each value with its range and lab group. Clamping holds every value +/// in range and the ungrouped swell below folding. The shipped file () holds one per +/// chapter, and the generator () writes it into the shader. Pure, so it +/// unit-tests without a session. +/// +public sealed record SeaState +{ + /// The bound on times . Each wave bunches the + /// surface by Q k A, the sharpness over the wave count. At the base gain the sum folds a crest at + /// 1. The sea-state grouping scales a wave by up to 1.8, so a grouped crest can fold sooner. + public const float FoldLimit = 1f; + + /// The narrowest a coast ramp may be, in metres, so its smoothstep keeps its edges apart. + public const float RampGap = 4f; + + /// The sea every chapter draws with nothing saved: the ocean's tune. + public static readonly SeaState Default = new(); + + /// Every tunable, in the order the lab lists them and the file writes them. + public static readonly IReadOnlyList Fields = new Field[] + { + new("height", "Height", Swell, 0f, 2f, 0.01f, s => s.Height, (s, v) => s with { Height = v }, + "sharpness x height is held at 1.0, where an ungrouped crest folds; grouped crests fold sooner. Length does not move the fold"), + new("length", "Length", Swell, 0.5f, 2f, 0.01f, s => s.Length, (s, v) => s with { Length = v }), + new("wind", "Wind (deg)", Swell, 0f, 360f, 1f, s => s.WindDeg, (s, v) => s with { WindDeg = v }), + new("sharpness", "Crest sharpness", Swell, 0.1f, 1f, 0.01f, s => s.Sharpness, (s, v) => s with { Sharpness = v }), + new("bend_m", "Bend, long waves (m)", Bending, 0f, 150f, 1f, s => s.BendMetres, (s, v) => s with { BendMetres = v }), + new("bend_rad", "Bend cap, short waves (rad)", Bending, 0f, 30f, 0.5f, s => s.BendRadians, (s, v) => s with { BendRadians = v }), + new("detail_slope", "Detail slope", Bending, 0f, 0.2f, 0.005f, s => s.DetailSlope, (s, v) => s with { DetailSlope = v }), + new("detail_drift", "Detail drift", Bending, 0f, 3f, 0.05f, s => s.DetailDrift, (s, v) => s with { DetailDrift = v }, + "steps by whole noise periods per csky_time wrap"), + new("foam_threshold", "Threshold (sigma)", Foam, 0.3f, 3f, 0.05f, s => s.FoamThreshold, (s, v) => s with { FoamThreshold = v }, + "lower forms more foam"), + new("foam_strength", "Strength", Foam, 0f, 0.5f, 0.01f, s => s.FoamStrength, (s, v) => s with { FoamStrength = v }), + new("foam_patch", "Patch size", Foam, 0.25f, 4f, 0.05f, s => s.FoamPatch, (s, v) => s with { FoamPatch = v }), + new("foam_cover", "Near cover", Foam, 0.1f, 0.6f, 0.01f, s => s.FoamCover, (s, v) => s with { FoamCover = v }), + new("swell_from", "Swell ramp from (m)", Coast, 0f, OceanMaskRaster.ShoreReach - RampGap, 1f, s => s.SwellFrom, (s, v) => s with { SwellFrom = v }), + new("swell_full", "Swell ramp full (m)", Coast, 16f, OceanMaskRaster.ShoreReach, 1f, s => s.SwellFull, (s, v) => s with { SwellFull = v }, + "the mask stops at 160 m; farther needs a rebake"), + new("look_from", "Look ramp from (m)", Coast, OceanMask.Cell, OceanMaskRaster.ShoreReach - RampGap, 1f, s => s.LookFrom, (s, v) => s with { LookFrom = v }, + "at least one mask texel, which keeps the coast tile seam"), + new("look_full", "Look ramp full (m)", Coast, 16f, OceanMaskRaster.ShoreReach, 1f, s => s.LookFull, (s, v) => s with { LookFull = v }), + new("tint_r", "Tint red", Coast, 0.5f, 1.5f, 0.01f, s => s.TintR, (s, v) => s with { TintR = v }), + new("tint_g", "Tint green", Coast, 0.5f, 1.5f, 0.01f, s => s.TintG, (s, v) => s with { TintG = v }), + new("tint_b", "Tint blue", Coast, 0.5f, 1.5f, 0.01f, s => s.TintB, (s, v) => s with { TintB = v }), + new("rough_near", "Roughness near", Coast, 0.02f, 0.8f, 0.01f, s => s.RoughNear, (s, v) => s with { RoughNear = v }), + new("rough_far", "Roughness far", Coast, 0.02f, 0.8f, 0.01f, s => s.RoughFar, (s, v) => s with { RoughFar = v }), + }; + + // The lab's groups, in the order its panel shows them. + private const string Swell = "Swell"; + private const string Bending = "Bending + detail"; + private const string Foam = "Foam"; + private const string Coast = "Coast + colour"; + + /// TUNE. The swell's height, the shader's wave_scale, on the grid and in the normals. + public float Height { get; init; } = 1f; + + /// A scale on every swell wave's length. Each wave's omega is rounded again to whole + /// cycles per csky_time wrap. + public float Length { get; init; } = 1f; + + /// TUNE. The wind the swell and the detail run with, degrees from +X toward +Z. + public float WindDeg { get; init; } = 20f; + + /// TUNE. The crest sharpness, the Gerstner choppiness shared over the swell's waves. + public float Sharpness { get; init; } = 0.55f; + + /// TUNE. How far the still field bends a long wave's crests, in metres. + public float BendMetres { get; init; } = 60f; + + /// TUNE. The most a short wave's phase bends, in radians, so crossing crests bend apart. + public float BendRadians { get; init; } = 14f; + + /// TUNE. The fine detail's slope per noise layer. + public float DetailSlope { get; init; } = 0.08f; + + /// A scale on every detail layer's drift. A drift of zero holds the detail still. + public float DetailDrift { get; init; } = 1f; + + /// TUNE. The standard deviations of the swell's Jacobian at which foam begins. It is full one + /// deviation further, so a whitecap grows and fades as its crest passes. + public float FoamThreshold { get; init; } = 1.3f; + + /// TUNE. The foam's brightness, the shader's foam_strength. + public float FoamStrength { get; init; } = 0.12f; + + /// A scale on the patch field's cells, which decide where whitecaps can form. + public float FoamPatch { get; init; } = 1f; + + /// TUNE. The share of a whitecap its sheet covers up close, at the foam strength over the + /// share. Its mean is the far foam, so the sheet's fade-in moves little brightness. + public float FoamCover { get; init; } = 0.3f; + + /// TUNE. Metres from the shore over which the swell's height fades in. It keeps a + /// displaced crest from lifting through a coplanar coast layer. + public float SwellFrom { get; init; } = 24f; + + /// TUNE. Where the swell reaches full height, at most the mask's reach. + public float SwellFull { get; init; } = OceanMaskRaster.ShoreReach; + + /// TUNE. Metres from the shore over which the look (slope, chop, foam, texture) fades in. The + /// ocean then matches the flat sheet where an opaque coast tile meets it. It clears the texel + /// beside the coast one, which the linear filter blends into the boundary. + public float LookFrom { get; init; } = 12f; + + /// TUNE. Where the look is full. + public float LookFull { get; init; } = 64f; + + /// A multiplier on the open sea's colour, red channel. The shore keeps the sheet's own. + public float TintR { get; init; } = 1f; + + /// The open sea's colour multiplier, green channel. + public float TintG { get; init; } = 1f; + + /// The open sea's colour multiplier, blue channel. + public float TintB { get; init; } = 1f; + + /// TUNE. The roughness up close, the shader's rough_near. + public float RoughNear { get; init; } = 0.2f; + + /// TUNE. The roughness far off, the shader's rough_far. + public float RoughFar { get; init; } = 0.3f; + + /// Whether the open sea's colour is tinted at all. An untinted sea writes no tint term. + public bool Tinted => TintR != 1f || TintG != 1f || TintB != 1f; + + /// The field named , or null. + public static Field? Find(string key) + { + foreach (var f in Fields) + { + if (string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase)) + return f; + } + + return null; + } + + /// The --debug-ocean grammar: open and <field>:<number>, + /// comma-separated. Keeps the tokens it understands, in order, and appends the rest to + /// when one is supplied. + public static string FilterOverrides(string spec, List? rejected = null) + { + var kept = new List(); + foreach (string token in spec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (token.Equals("open", StringComparison.OrdinalIgnoreCase) || ParseToken(token) != null) + kept.Add(token); + else + rejected?.Add(token); + } + + return string.Join(",", kept); + } + + /// Whether a --debug-ocean value asks for the lab's panel at launch. + public static bool OverridesOpen(string? spec) => + spec != null && Array.Exists(spec.Split(',', StringSplitOptions.TrimEntries), + t => t.Equals("open", StringComparison.OrdinalIgnoreCase)); + + /// This sea with every <field>:<number> token of a filtered + /// --debug-ocean value applied left to right, then clamped. + public SeaState WithOverrides(string? spec) + { + var s = this; + foreach (string token in (spec ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (ParseToken(token) is var (field, value)) + s = field.With(s, value); + } + + return s.Clamped(); + } + + /// This sea with every value in its field's range and each coast ramp at least + /// wide. Sharpness times height is at most , though + /// grouped crests can still fold. A value that is not a number takes its default. Every path into + /// the shader goes through here. + public SeaState Clamped() + { + var s = this; + foreach (var f in Fields) + { + float v = f.Get(s); + float c = float.IsFinite(v) ? Math.Clamp(v, f.Min, f.Max) : f.Default; + if (c != v) + s = f.With(s, c); + } + + if (s.Height * s.Sharpness > FoldLimit) + s = s with { Height = FoldLimit / s.Sharpness }; + if (s.SwellFrom > s.SwellFull - RampGap) + s = s with { SwellFrom = s.SwellFull - RampGap }; + if (s.LookFrom > s.LookFull - RampGap) + s = s with { LookFrom = s.LookFull - RampGap }; + return s; + } + + /// This sea as the file holds it: clamped, each changed field at + /// precision and each unchanged one at its default. A value that rounding carries past a bound + /// holds is rounded toward the bound instead, so the file reads back with no clamp. + public SeaState Written() + { + var clamped = Clamped(); + var s = clamped; + foreach (var f in Fields) + s = f.With(s, clamped.Differs(f) ? (float)Field.Rounded(f.Get(clamped)) : f.Default); + + // Every bound one field sets on another reads a field that only its range clamps, so one pass settles. + var held = s.Clamped(); + foreach (var f in Fields) + { + float v = f.Get(s), c = f.Get(held); + if (c != v) + s = f.With(s, (float)Field.RoundedToward(c, up: c > v)); + } + + return s; + } + + /// Whether differs from its default as the file writes it. + public bool Differs(Field field) => Field.Rounded(field.Get(this)) != Field.Rounded(field.Default); + + /// The fields that differ from the defaults as key:value, or defaults. + public string Describe() + { + var parts = new List(); + foreach (var f in Fields) + { + if (Differs(f)) + parts.Add($"{f.Key}:{f.Format(f.Get(this))}"); + } + + return parts.Count == 0 ? "defaults" : string.Join(",", parts); + } + + private static (Field Field, float Value)? ParseToken(string token) + { + int colon = token.IndexOf(':'); + if (colon <= 0 || Find(token[..colon].Trim()) is not { } field + || !float.TryParse(token[(colon + 1)..].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float value) + || !float.IsFinite(value)) + return null; + return (field, value); + } + + /// One tunable: its file and flag key, its lab label and group, its range and slider step. + /// It reads and sets its value, and carries a note the lab shows under its slider. + public sealed record Field(string Key, string Label, string Group, float Min, float Max, float Step, + Func Get, Func With, string Note = "") + { + /// The value holds. + public float Default => Get(SeaState.Default); + + /// A value as the file writes it: four decimals, so a float's tail never reads as a change. + public static double Rounded(float v) => Math.Round((double)v, 4); + + /// The four-decimal value nearest at or above it when + /// , else at or below it, compared as the float the file reads back. + public static double RoundedToward(float v, bool up) + { + double r = Rounded(v); + if (up ? (float)r < v : (float)r > v) + r = Math.Round(r + (up ? 1e-4 : -1e-4), 4); + return r; + } + + /// A value at the precision its slider steps in, invariant. + public string Format(float v) => + v.ToString(Step >= 1f ? "0" : Step >= 0.1f ? "0.0" : Step >= 0.01f ? "0.00" : "0.000", CultureInfo.InvariantCulture); + } +} diff --git a/CSVM/src/Effects/SeaState.cs.uid b/CSVM/src/Effects/SeaState.cs.uid new file mode 100644 index 000000000..6ea6933c3 --- /dev/null +++ b/CSVM/src/Effects/SeaState.cs.uid @@ -0,0 +1 @@ +uid://bh2vx3o2q4kma diff --git a/CSVM/src/Flight/Camera/SpyglassView.cs b/CSVM/src/Flight/Camera/SpyglassView.cs index efc7adee2..7775ec9d9 100644 --- a/CSVM/src/Flight/Camera/SpyglassView.cs +++ b/CSVM/src/Flight/Camera/SpyglassView.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Threading; using Godot; namespace CSVM.Flight.Camera; @@ -25,6 +27,7 @@ public sealed partial class SpyglassView : SubViewport private Camera3D? _pane; private uint _ownLayer; private bool _live; + private Counters? _counters; /// Whether the picture is rendering this frame. public bool Live => _live; @@ -65,13 +68,14 @@ public static SpyglassView Build(Camera3D? pane, uint ownLayer) } /// The picture's cull mask: less the one layer the pilot's - /// own aeroplane is drawn on (), every other - /// aircraft kept. Under it also trades the world sun for its - /// shadowless copy (), so the picture renders no shadow pass. + /// own aeroplane is drawn on (). Under + /// the shadowless sun () replaces the world's, + /// and makes it show the flat sea, not the waves. /// ⚠ NOT decoded, and do not put the airframe back on the decode's authority: the original's /// update touches no per-object visibility (docs/org/spyglass.md). public static uint DiscMask(uint paneMask, uint ownLayer, bool enhanced) => enhanced - ? (paneMask & ~ownLayer & ~UI.Boards.SplitScreen.SunLayer) | UI.Boards.SplitScreen.SpyglassSunLayer + ? (paneMask & ~ownLayer & ~UI.Boards.SplitScreen.SunLayer) + | UI.Boards.SplitScreen.SpyglassSunLayer | UI.Boards.SplitScreen.FlatSeaLayer : paneMask & ~ownLayer; /// The square the picture renders at for a disc pixels across. @@ -82,13 +86,16 @@ public static int RenderSide(int side) => Utils.GraphicsMode.Enhanced ? Mathf.Max(side, Mathf.CeilToInt(MinInternalSide / Utils.RenderScaleSetting.Scale)) : side; - /// The pictures rendering now, and the visible and shadow draw calls the renderer last - /// made into them, summed. The --perf readout's spyglass line, since each disc is a - /// viewport of its own whose share no frame-wide counter separates. - public static (int Live, long Draws, long ShadowDraws) Census() + /// The pictures rendering now, and the visible and shadow draw calls, the visible + /// primitives and the GPU milliseconds the renderer last spent on them, summed. The + /// --perf readout's spyglass line, since each disc is a viewport of its own whose share + /// no frame-wide counter separates. The counts trail the frame by a draw (). + /// + public static (int Live, long Draws, long ShadowDraws, long Prims, double GpuMs) Census() { int live = 0; - long draws = 0, shadowDraws = 0; + long draws = 0, shadowDraws = 0, prims = 0; + double gpuMs = 0; foreach (var view in InTree) { if (!view._live) @@ -96,12 +103,16 @@ public static (int Live, long Draws, long ShadowDraws) Census() continue; } + view._counters ??= new Counters(view.GetViewportRid()); + var read = view._counters.Read(); live++; - draws += view.GetRenderInfo(RenderInfoType.Visible, RenderInfo.DrawCallsInFrame); - shadowDraws += view.GetRenderInfo(RenderInfoType.Shadow, RenderInfo.DrawCallsInFrame); + draws += read.Draws; + shadowDraws += read.ShadowDraws; + prims += read.Prims; + gpuMs += read.GpuMs; } - return (live, draws, shadowDraws); + return (live, draws, shadowDraws, prims, gpuMs); } /// Point the picture and start it rendering. is the disc's @@ -148,4 +159,54 @@ public void Idle() /// public override void _ExitTree() => InTree.Remove(this); + + // One picture's census counters, published from the render thread as Utils.MeasuredRenderTime + // publishes the root viewport's times. Built at the first census, so only a run that reads one + // measures the picture's GPU time. + // ⚠ Never call the viewport's render-info getter from the main thread. Under the separate render + // thread each call waits for it, stalling every frame a disc renders. + private sealed class Counters + { + private readonly Rid _viewport; + private readonly Callable _publish; + private long _draws; + private long _shadowDraws; + private long _prims; + private long _gpuBits; + + public Counters(Rid viewport) + { + _viewport = viewport; + RenderingServer.ViewportSetMeasureRenderTime(viewport, true); + _publish = Callable.From(Publish); + } + + public (long Draws, long ShadowDraws, long Prims, double GpuMs) Read() + { + if (RenderingServer.IsOnRenderThread()) + { + Publish(); + } + else + { + RenderingServer.CallOnRenderThread(_publish); + } + + return (Interlocked.Read(ref _draws), Interlocked.Read(ref _shadowDraws), Interlocked.Read(ref _prims), + BitConverter.Int64BitsToDouble(Interlocked.Read(ref _gpuBits))); + } + + // Runs on the render thread, where every getter answers directly. + private void Publish() + { + Interlocked.Exchange(ref _draws, RenderingServer.ViewportGetRenderInfo(_viewport, + RenderingServer.ViewportRenderInfoType.Visible, RenderingServer.ViewportRenderInfo.DrawCallsInFrame)); + Interlocked.Exchange(ref _shadowDraws, RenderingServer.ViewportGetRenderInfo(_viewport, + RenderingServer.ViewportRenderInfoType.Shadow, RenderingServer.ViewportRenderInfo.DrawCallsInFrame)); + Interlocked.Exchange(ref _prims, RenderingServer.ViewportGetRenderInfo(_viewport, + RenderingServer.ViewportRenderInfoType.Visible, RenderingServer.ViewportRenderInfo.PrimitivesInFrame)); + Interlocked.Exchange(ref _gpuBits, + BitConverter.DoubleToInt64Bits(RenderingServer.ViewportGetMeasuredRenderTimeGpu(_viewport))); + } + } } diff --git a/CSVM/src/Flight/Weapons/Loadout.cs b/CSVM/src/Flight/Weapons/Loadout.cs index 06ddbd9f2..10d0b8a90 100644 --- a/CSVM/src/Flight/Weapons/Loadout.cs +++ b/CSVM/src/Flight/Weapons/Loadout.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using CSVM.Mech3; using CSVM.Utils; @@ -31,6 +32,13 @@ public sealed class StockLoadouts /// where GlobalizePath + System.IO cannot reach it. public static string DefaultPath => "res://data/stock_loadouts.json"; + /// A second file of the same shape, set once at startup. Its planes join the + /// committed ones when is loaded, replacing any committed plane on + /// the same model (). The synthetic-data switch names the stand-ins' fits + /// here, since their invented aircraft are in no committed table. Null, the default, reads the + /// committed file alone. + public static string? Supplement { get; set; } + public IReadOnlyDictionary All => _byDef; /// The Ammo Selection screen's dropdown rosters, empty when the file omits them. @@ -56,7 +64,68 @@ public static StockLoadouts Load(string? path = null) return loadouts; } using var doc = JsonDocument.Parse(viaGodot ? Godot.FileAccess.GetFileAsBytes(path) : File.ReadAllBytes(path)); - if (!doc.RootElement.TryGetProperty("planes", out var planes) || planes.ValueKind != JsonValueKind.Object) + ReadPlanes(doc.RootElement, path, loadouts._byDef); + if (path == DefaultPath && Supplement is { } extra) + { + // ⚠ Never skip a missing supplement silently: a run that set one expects its planes armed. + loadouts.Overlay(extra); + } + + if (doc.RootElement.TryGetProperty("selectable", out var selectable) + && selectable.ValueKind == JsonValueKind.Object) + { + loadouts.Options = new LoadoutOptions + { + GunAmmo = OptionList(selectable, "gun_ammo"), + PylonOrdnance = OptionList(selectable, "pylon_ordnance"), + }; + } + return loadouts; + } + + /// The def's stock loadout, or null when the plane isn't in the file. + public LoadoutDef? For(string defName) => _byDef.TryGetValue(defName, out var d) ? d : null; + + /// The stock loadout of whichever def flies + /// (player_pfighter), or null. The menu knows a plane by its model node, not its def + /// name, and the two differ often enough not to be guessable. + public LoadoutDef? ForModel(string modelNode) + { + foreach (var def in _byDef.Values) + { + if (string.Equals(def.Model, modelNode, StringComparison.OrdinalIgnoreCase)) + { + return def; + } + } + return null; + } + + /// Lays a supplement file's planes over the loaded ones. A supplement plane replaces any + /// loaded def flying its model, so a lookup by model (the menus' ) finds + /// the supplement's fit. It never finds the one the supplement stands in for. + internal void Overlay(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllBytes(path)); + var added = new Dictionary(StringComparer.OrdinalIgnoreCase); + ReadPlanes(doc.RootElement, path, added); + foreach (var def in added.Values) + { + foreach (var shadowed in _byDef.Values + .Where(d => string.Equals(d.Model, def.Model, StringComparison.OrdinalIgnoreCase)) + .Select(d => d.Def) + .ToList()) + { + _byDef.Remove(shadowed); + } + + _byDef[def.Def] = def; + } + } + + private static void ReadPlanes(JsonElement root, string path, Dictionary into) + { + if (!root.TryGetProperty("planes", out var planes) || planes.ValueKind != JsonValueKind.Object) { throw new InvalidDataException($"stock loadouts: no 'planes' object in {path}"); } @@ -80,6 +149,10 @@ public static StockLoadouts Load(string? path = null) Caliber = Int(g, "caliber"), Ammo = Str(g, "ammo"), Turret = g.TryGetProperty("turret", out var t) && t.ValueKind == JsonValueKind.True, + // A weapon outside the caliber matrix is named outright (docs/formats/loadouts.md). + WeaponId = g.TryGetProperty("weapon", out var w) && w.ValueKind == JsonValueKind.String + ? w.GetString() + : null, }; if (g.TryGetProperty("markers", out var markers) && markers.ValueKind == JsonValueKind.Array) { @@ -98,37 +171,8 @@ public static StockLoadouts Load(string? path = null) { def.Hardpoints = new HardpointSpec { Count = Int(hp, "count"), Stock = Strings(hp, "stock") }; } - loadouts._byDef[def.Def] = def; + into[def.Def] = def; } - - if (doc.RootElement.TryGetProperty("selectable", out var selectable) - && selectable.ValueKind == JsonValueKind.Object) - { - loadouts.Options = new LoadoutOptions - { - GunAmmo = OptionList(selectable, "gun_ammo"), - PylonOrdnance = OptionList(selectable, "pylon_ordnance"), - }; - } - return loadouts; - } - - /// The def's stock loadout, or null when the plane isn't in the file. - public LoadoutDef? For(string defName) => _byDef.TryGetValue(defName, out var d) ? d : null; - - /// The stock loadout of whichever def flies - /// (player_pfighter), or null. The menu knows a plane by its model node, not its def - /// name, and the two differ often enough not to be guessable. - public LoadoutDef? ForModel(string modelNode) - { - foreach (var def in _byDef.Values) - { - if (string.Equals(def.Model, modelNode, StringComparison.OrdinalIgnoreCase)) - { - return def; - } - } - return null; } private static string Str(JsonElement e, string key) => @@ -539,12 +583,41 @@ public static Loadout BindWingman(LoadoutDef fitted, Node3D plane, WeaponDefs we /// keeps its weapon; one it does not defaults to the stock's first gun weapon. /// ⚠ Every synthesized group is fireable, even a stock turret slot, a deliberate lab-only /// difference. Binds through the same every other loadout uses. - public static Loadout ForRig(Node3D plane, WeaponDefs weapons, LoadoutDef? stock) + public static Loadout ForRig(Node3D plane, WeaponDefs weapons, LoadoutDef? stock) => + Bind(RigDef(CollectMarkers(plane).Keys, stock), plane, weapons); + + /// Applies the --rocket=<wep_id> testing override: replaces every hardpoint's + /// ordnance with the named weapon, resetting each pylon's capacity/ammo to that weapon's + /// CLUSTER_SIZE. A no-op (with a warning) if the id is unknown. Must run before the pylon + /// models are mounted and the controller's ordnance-type list is built. All 11 stock loadouts + /// carry HE (wep_06), so this is the only way to exercise a different pylon model. + public void ApplyRocketOverride(WeaponDefs weapons, string wepId, bool verbose) + { + if (weapons.Get(wepId) is not { } weapon) + { + Log.Warn("weapons", $"--rocket='{wepId}' is not a known weapon id, hardpoints keep their stock ordnance"); + return; + } + int per = weapon.ClusterSize ?? 0; + foreach (var hp in Hardpoints) + { + hp.Weapon = weapon; + hp.Capacity = per; + hp.Ammo = per; + } + if (verbose) + { + Log.Info("core", $"--rocket: hardpoints -> {weapon.Id} ({weapon.Name}), flyout model '{weapon.Flyout?.Model ?? "-"}', {per}/pylon"); + } + } + + /// The node-free half of : the def it binds, built from the rig's + /// marker names alone. A unit pins it without a live plane. + internal static LoadoutDef RigDef(IEnumerable markerNames, LoadoutDef? stock) { - var markerNodes = CollectMarkers(plane); var firepoints = new SortedSet(); var pylons = new SortedSet(); - foreach (var name in markerNodes.Keys) + foreach (var name in markerNames) { if (MarkerRig.Classify(name, out var kind, out int ord)) { @@ -601,6 +674,9 @@ public static Loadout ForRig(Node3D plane, WeaponDefs weapons, LoadoutDef? stock Mount = stockSpec?.Mount ?? $"Gun Group {slot}", Caliber = stockSpec?.Caliber ?? firstStockGun?.Caliber ?? 30, Ammo = stockSpec?.Ammo ?? firstStockGun?.Ammo ?? "slug", + // A named weapon sits outside the caliber matrix, so Caliber and Ammo cannot rebuild + // it. Null, which every shipped fit carries, leaves Bind composing the matrix id. + WeaponId = stockSpec != null ? stockSpec.WeaponId : firstStockGun?.WeaponId, Markers = markers, Turret = false, }); @@ -621,32 +697,7 @@ public static Loadout ForRig(Node3D plane, WeaponDefs weapons, LoadoutDef? stock }; } - return Bind(def, plane, weapons); - } - - /// Applies the --rocket=<wep_id> testing override: replaces every hardpoint's - /// ordnance with the named weapon, resetting each pylon's capacity/ammo to that weapon's - /// CLUSTER_SIZE. A no-op (with a warning) if the id is unknown. Must run before the pylon - /// models are mounted and the controller's ordnance-type list is built. All 11 stock loadouts - /// carry HE (wep_06), so this is the only way to exercise a different pylon model. - public void ApplyRocketOverride(WeaponDefs weapons, string wepId, bool verbose) - { - if (weapons.Get(wepId) is not { } weapon) - { - Log.Warn("weapons", $"--rocket='{wepId}' is not a known weapon id, hardpoints keep their stock ordnance"); - return; - } - int per = weapon.ClusterSize ?? 0; - foreach (var hp in Hardpoints) - { - hp.Weapon = weapon; - hp.Capacity = per; - hp.Ammo = per; - } - if (verbose) - { - Log.Info("core", $"--rocket: hardpoints -> {weapon.Id} ({weapon.Name}), flyout model '{weapon.Flyout?.Model ?? "-"}', {per}/pylon"); - } + return def; } // The hardpoint list read in physical mount order: list positions sorted by pylon number. diff --git a/CSVM/src/Flight/Weapons/MissionTargets.cs b/CSVM/src/Flight/Weapons/MissionTargets.cs index c54823ab1..3e3897e06 100644 --- a/CSVM/src/Flight/Weapons/MissionTargets.cs +++ b/CSVM/src/Flight/Weapons/MissionTargets.cs @@ -51,6 +51,17 @@ public static MissionTargets Load(string missionZrdrPath) => public static MissionTargets Load(string missionZrdrPath, string chapterZrdrPath) => TryLoad(missionZrdrPath) ?? TryLoad(chapterZrdrPath) ?? new MissionTargets(); + /// A table built in code for a stage that ships no targets.json. Every key is + /// flagged objective with no display keys, for a mode that labels each site itself. + /// + public static MissionTargets Objectives(IEnumerable keys) + { + var targets = new MissionTargets(); + foreach (var key in keys) + targets._byNode[key] = new MissionTarget(null, null, null, Objective: true); + return targets; + } + /// The display keys for a target key, or an all-null /// if the key has no targets.json entry. public MissionTarget For(string nodeName) => diff --git a/CSVM/src/Launch/EnhancedLook.cs b/CSVM/src/Launch/EnhancedLook.cs index 9af91320a..c72fd52e7 100644 --- a/CSVM/src/Launch/EnhancedLook.cs +++ b/CSVM/src/Launch/EnhancedLook.cs @@ -199,6 +199,15 @@ public static void ApplyViewDistance(string? word, GameSession? session) Log.Info("world", $"view distance: {word}, clutter reach x{ViewDistance.ClutterReach():0.#} (applied live)"); } + /// The resolved word on the running world: the session + /// builds the wave ocean or drops it, and with it the flat sheet's hole. Original mode moves + /// nothing, and a later switch to Enhanced builds what the word asks for. + public static void ApplyWaterQuality(GameSession? session) + { + session?.FollowWaterQuality(); + Log.Info("world", $"water quality: {WaterQualitySetting.Word} (applied live, ocean {(session?.OceanBuilt == true ? "built" : "not built")})"); + } + /// A texture's alpha depth on the running world. The archive uploads its alpha-plane /// textures again at the standing mode's depth. Then the puffer atlases and painted skins made /// from them are baked again, each in place. The session calls it first on a switch. diff --git a/CSVM/src/Launch/GameSession.cs b/CSVM/src/Launch/GameSession.cs index c0cfad7dd..eae55b91c 100644 --- a/CSVM/src/Launch/GameSession.cs +++ b/CSVM/src/Launch/GameSession.cs @@ -148,6 +148,7 @@ public partial class GameSession : Node3D private readonly string _soundsPath; private readonly string _interpPath; private readonly string _messagesPath; + private readonly DecodeCache? _decode; // the extracted UI archive (paint patterns) private readonly string _rofPath; // Process-scoped, owned by the Launcher; the --damage-test/--effects-test/--weapon-test/ @@ -282,6 +283,9 @@ public partial class GameSession : Node3D // session builds hangs under _worldRoot, so Esc-to-menu can free it and a new session node // build again. The camera, lights and global shader params live on the Launcher and persist. private Node3D? _worldRoot; + // The empty stage's match arena, the flag and rearm nodes a Dogfight there stands on. Null on a + // chapter, whose own world holds them. + private Node3D? _stageArena; // the session's LIGHT_STATE point lights (see WorldLights) private WorldLights? _worldLights; // The faithful path's projected aircraft shadow, null in enhanced mode, which casts shadow maps @@ -293,6 +297,14 @@ public partial class GameSession : Node3D // The enhanced-only world layers and the mode-dependent builds, held so a live graphics-mode // switch can build, free or rewrite each (ApplyGraphicsMode). Null where the build made none. private Effects.ScorchField? _scorches; + private Effects.Ocean? _ocean; + private bool _oceanEligible; + // The chapter's saved sea, and the one the ocean draws: the saved one under any --debug-ocean + // overrides and the ocean lab's edits. An ocean rebuilt by a live switch takes the drawn one. + private Effects.SeaState _seaSaved = Effects.SeaState.Default; + private Effects.SeaState _sea = Effects.SeaState.Default; + // The world runtime whose motions say which hulls the ocean follows; set in every mode it builds in. + private AnimRuntime? _oceanRuntime; private ClutterBuilder? _clutter; private SceneBuilder? _worldScene; // The session-owned texture archive, kept open past the build scope so the data-driven crash can @@ -335,6 +347,7 @@ public GameSession(SessionSpec spec, LauncherContext ctx) _soundsPath = ctx.SoundsPath; _interpPath = ctx.InterpPath; _messagesPath = ctx.MessagesPath; + _decode = ctx.Decode; _rofPath = ctx.RofPath; _probeRunner = ctx.ProbeRunner; _captureDirector = ctx.CaptureDirector; @@ -367,6 +380,12 @@ public GameSession(SessionSpec spec, LauncherContext ctx) /// launchscreen only once a world is actually up). public bool InSession { get; private set; } + /// Whether the wave ocean stands in this session's world. + public bool OceanBuilt => _ocean != null; + + /// The static world's scene builder, null outside a built world. Read by the suites. + internal SceneBuilder? WorldScene => _worldScene; + /// Has the session drawn the first frame the player is meant to see. That frame is /// an intro's camera posed onto the rigs, or the flown aeroplane on its spawn under its own /// HUD. It latches at the end of the frame that reaches it, and it is what the start cover @@ -784,6 +803,12 @@ public bool StartSession() } FinishFraming(state); + _oceanEligible = state.NodeSubtree == null && (_spec.Fly || _spec.Freecam); + _oceanRuntime = state.WorldRuntime; + ResolveSea(); + FollowOcean(); + if (_oceanEligible && Effects.Ocean.Covers(_spec.Chapter)) + _labs!.BuildOceanLab(_seaSaved, _sea, () => _ocean, ApplySea); BuildWorldMerge(state); // By default only once this process has switched. The warm-up costs what one switch does, // and a player who never switches would pay it at every load. @@ -812,6 +837,7 @@ public void ApplyGraphicsMode() // After the world's own materials, whose blend verdicts decide what merges. _worldMerge?.Follow(enhanced); SwitchProfile.Mark("merge"); + FollowOcean(); _clutter?.Recut(); _edgeExtender?.FollowClutterFade(); SwitchProfile.Mark("clutter"); @@ -866,6 +892,10 @@ public void FollowClutterFade() _edgeExtender?.FollowClutterFade(); } + /// A live Water Quality change builds or drops the wave ocean + /// (). The graphics-mode switch follows it too. + public void FollowWaterQuality() => FollowOcean(); + /// After the launcher re-dressed the session sun: each cockpit pass re-takes it, then /// the zone is written again over them all. ⚠ Keep the zone last. The Environment and sun /// writes put back defaults the zone's sky colour, energies and shadow distance overwrite. @@ -1275,7 +1305,7 @@ private void LoadArchives(BuildState state) // ambient SOUND_NODE emitters and needs the archive while it runs. var archives = SessionArchives.OpenFor( _spec.AnimLab ? ArchiveIntent.Lab : ArchiveIntent.Session, - state.GamezPath, state.TexturesPath, state.SoundsPath, state.ZrdrPath, state.Mute); + state.GamezPath, state.TexturesPath, state.SoundsPath, state.ZrdrPath, state.Mute, _decode); state.Gamez = archives.Gamez; state.Textures = archives.Textures; state.Sounds = archives.Sounds; @@ -1363,9 +1393,10 @@ private bool ResolveNodeSubtree(BuildState state) private void BuildEmptyStage(BuildState state) { long mark = StartupProfile.Mark(); - var stage = EmptyStage.Build(collision: _spec.Fly || _spec.ForceCollision); + var stage = EmptyStage.Build(collision: _spec.Fly || _spec.ForceCollision, arena: _spec.Versus); StartupProfile.Record("world", mark); _plane = stage.Root; + _stageArena = stage.Arena; state.MeshInstances = stage.MeshInstanceCount; state.Colliders = stage.ColliderCount; state.What = "empty stage"; @@ -1433,6 +1464,7 @@ private bool BuildWorldStage(BuildState state) InterpPath = state.InterpPath, MissionZrdrPath = state.MissionZrdrPath, ChapterZrdrPath = SessionPaths.ChapterZrdr(state.DataRoot, _spec.Chapter), + Decode = _decode, EffectsParent = _worldRoot!, // The one instance the weather rig publishes to and the player's own effects read. // Without it the world's emitters hold the camera-less still-air null object, which @@ -1735,7 +1767,7 @@ private void BuildFlightRigs(BuildState state) // gamez is then that chapter's, and planes.zbd has to be loaded here as everywhere else. var planesGamez = _spec.EmptyStage && _spec.Zep == null ? state.Gamez - : GameZ.Load(state.PlanesGamezPath); + : _decode?.Gamez(state.PlanesGamezPath) ?? GameZ.Load(state.PlanesGamezPath); StartupProfile.Record("gamez", mark); // Stats are per plane, not per player (splitscreen players can pick // different aircraft), load each distinct one once, logging it as it appears. @@ -2412,7 +2444,7 @@ CamParams CamParamsFor(string plane) Radio = voices.Radio, GroundAt = GroundSampler(), }); - _dogfight?.WireRearmBases(state.WorldRuntime, state.ZrdrPath, _zeppelins); + _dogfight?.WireRearmBases(state.WorldRuntime, _stageArena, state.ZrdrPath, _zeppelins); _wire.WirePositionalStarts(_landings, _ladder, state.WorldRuntime); _wire.WireCutscenes(_cutscene); @@ -2436,6 +2468,7 @@ CamParams CamParamsFor(string plane) SheetCarriesObjectives = boards.SheetCarriesObjectives, Stunting = stuntZones != null, Dogfight = _dogfight, + StageArena = _stageArena, }); if (_rigs.Count > 1) @@ -2510,6 +2543,101 @@ private void LogBuildSummary(BuildState state, Stopwatch sw) Log.Info("world", $"[textures] {state.Textures.MissingTextures.Count} referenced texture(s) absent from this install: {string.Join(", ", state.Textures.MissingTextures)}"); } + // The Enhanced wave ocean, built on the first Enhanced frame that asks for waves. A switch back + // to Original or to flat water drops it, and the sea sheet draws again because the shared + // switch resets. + private void FollowOcean() + { + if (!GraphicsMode.Enhanced || !WaterQualitySetting.DrawsWaves) + { + Drop(_ocean); + _ocean = null; + return; + } + if (_ocean != null || !_oceanEligible || _plane == null || _worldScene == null || _sessionTextures == null + || !Effects.Ocean.Covers(_spec.Chapter)) + return; + if ((_spec.SkippedPasses & EnhancedPasses.Ocean) != 0) + { + Log.Info("world", $"ocean: skipped (--no-ocean)"); + return; + } + var hulls = new HashSet(); + if (_surfaceVehicles != null) + { + foreach (var v in _surfaceVehicles.Vessels) + hulls.Add(v.Body); + } + _ocean = Effects.Ocean.Create(_plane, _worldScene, _sessionTextures, OceanHulls, hulls, AnimatedMovers(hulls), _sea, _spec.OceanMaskPath); + if (_ocean != null) + _plane.AddChild(_ocean); + } + + // The chapter's sea from the shipped file, read once per session that can stand an ocean, under + // any --debug-ocean overrides. A chapter with no entry draws the defaults. + private void ResolveSea() + { + if (!_oceanEligible || !Effects.Ocean.Covers(_spec.Chapter)) + return; + _seaSaved = Effects.OceanSeas.Load().For(_spec.Chapter); + _sea = _seaSaved.WithOverrides(_spec.DebugOcean); + string debug = _spec.DebugOcean is { Length: > 0 } spec ? $" (--debug-ocean={spec})" : ""; + Log.Info("world", $"ocean: sea {_spec.Chapter} saved={_seaSaved.Describe()} drawn={_sea.Describe()}{debug}"); + } + + // The ocean lab's edit: the standing ocean takes it at once, and a rebuilt one takes it too. + private void ApplySea(Effects.SeaState sea) + { + _sea = sea.Clamped(); + _ocean?.Apply(_sea); + } + + // The world nodes the bound program's played motions carry, as the runtime resolves them. The + // roster hulls are left out; they have their own source. + private List AnimatedMovers(HashSet roster) + { + var movers = new List(); + if (_oceanRuntime is not { } runtime || _plane == null) + return movers; + var seen = new HashSet(); + foreach (var def in runtime.ProgramDefs) + { + var moving = Effects.OceanMovers.MovingEvents(def); + if (moving.Count == 0) + continue; + foreach (var node in runtime.TargetsOf(def, moving)) + { + if (node != _plane && GodotObject.IsInstanceValid(node) && !InRoster(node) && seen.Add(node)) + movers.Add(node); + } + } + var names = movers.Select(m => m.Name.ToString()).OrderBy(n => n, StringComparer.Ordinal); + Log.Debug("world", $"ocean: movers resolved [{string.Join(", ", names)}]"); + return movers; + + bool InRoster(Node node) + { + for (Node? n = node; n != null; n = n.GetParent()) + { + if (roster.Contains(n)) + return true; + } + return false; + } + } + + // The roster hulls the ocean calms around, each over its own waterline. + private IEnumerable OceanHulls() + { + if (_surfaceVehicles == null) + yield break; + foreach (var v in _surfaceVehicles.Vessels) + { + if (GodotObject.IsInstanceValid(v.Body) && v.Body.IsVisibleInTree()) + yield return v.Body; + } + } + // Flight only: the inspection modes pick and edit single nodes, which a merged draw would not show. // ⚠ Keep it last in the build, after every placement and hide, so what stands visible is what // the mission shows. diff --git a/CSVM/src/Launch/InspectionLabs.cs b/CSVM/src/Launch/InspectionLabs.cs index 62ab689dc..347f6b153 100644 --- a/CSVM/src/Launch/InspectionLabs.cs +++ b/CSVM/src/Launch/InspectionLabs.cs @@ -373,6 +373,22 @@ public void BuildFreecam(BuildState state, SpawnPicker spawns) Log.Info("core", $"freecam: spectator camera at ({camPos.X:0}, {camPos.Y:0}, {camPos.Z:0}), hold RMB to look, WASD/QE to move, Shift boost, wheel sets speed; click an object to select it, PgUp/PgDn walk its ancestor ladder (Home/End jump), N opens the node lab, F19 the damage lab on whatever destructible is selected"); } + /// The ocean lab (Shift+F1), in --freecam alone: a slider per field of the + /// chapter's sea, applied live through , with Save into the shipped file. + /// Every other mode builds none, so no other session reads Shift+F1. + public void BuildOceanLab(SeaState saved, SeaState start, Func ocean, Action apply) + { + var spec = _in.Spec; + if (!spec.Freecam) + return; + string? savePath = OceanSeas.SourceTreePath(); + _in.WorldRoot.AddChild(new OceanLab(spec.Chapter.ToUpperInvariant(), saved, start, ocean, apply, savePath) + { + StartOpen = SeaState.OverridesOpen(spec.DebugOcean), + }); + Log.Info("ui", $"ocean lab ready (Shift+F1), save {savePath ?? "off in an exported build"}"); + } + /// The damage lab in flight (F19), the viewer's panel bound to P1's real damage. A /// dialled-in state then drives the HUD and can be flown. Splitscreen binds P1 only, since the /// panel is one overlay, not one per pane. diff --git a/CSVM/src/Launch/Launcher.cs b/CSVM/src/Launch/Launcher.cs index 1f1457279..2c54a9e83 100644 --- a/CSVM/src/Launch/Launcher.cs +++ b/CSVM/src/Launch/Launcher.cs @@ -233,8 +233,9 @@ public partial class Launcher : Node3D private int _perfFrames; private double _perfProcess, _perfGpu, _perfCpuRender, _perfPhysics, _perfSetup; private double _perfDraws, _perfPrims, _perfNodes, _perfMem; - // The spyglass discs' own counts over the window (SpyglassView.Census), split out of draws. - private double _perfDiscs, _perfDiscDraws, _perfDiscShadowDraws; + // The spyglass discs' own counts and GPU time over the window (SpyglassView.Census), split out + // of draws, prims and the root viewport's gpu_ms. + private double _perfDiscs, _perfDiscDraws, _perfDiscShadowDraws, _perfDiscPrims, _perfDiscGpu; // The --perf GC readout. Built with the first --perf frame rather than in _Ready, so a run // without the flag subscribes to no runtime events at all. @@ -285,6 +286,9 @@ public partial class Launcher : Node3D // so a git worktree can run the game, /extracted/, /CrimsonSkiesGame/ and /tools/ are // git-ignored, so a worktree checkout has none of them and cannot otherwise build or verify. private string _dataRoot = ""; + // Why --synthetic-data could not write its tree, or null. The run quits on it once the log is + // open. Until then the data root names the unwritten tree, so nothing real is read. + private string? _syntheticError; private string _planesGamezPath = ""; // extracted/planes.zip, the aircraft models (always this) private string _zrdrPath = ""; private string _soundsPath = ""; @@ -392,6 +396,7 @@ public override void _Ready() if (_spec.DataRoot is { } dataRootArg) _dataRoot = Path.GetFullPath(dataRootArg); if (_dataRoot != _repoRoot) Log.Info("core", $"data root: {_dataRoot} (repo root {_repoRoot})"); + ResolveSyntheticData(); // An override is used verbatim; everything else derives from the data root. var planesGamezPath = Path.Combine(_dataRoot, "extracted", "planes.zip"); @@ -544,6 +549,15 @@ public override void _Ready() ?? Path.Combine(Log.DirectoryFor(_repoRoot, _exported), $"{_spec.ModeName}-nolog.hitches.jsonl"); _hitchSidecar = new HitchSidecar(hitchLogPath, _hitchMonitor.Last.Ring.Length); + // A failed --synthetic-data tree ends the run. Reading the install instead would pass it on + // data nobody asked for, and an empty tree would only skip every suite. + if (_syntheticError != null) + { + Log.Error("core", $"--synthetic-data could not write its tree: {_syntheticError}"); + GetTree().Quit(1); + return; + } + // --extract builds no world and no menu: it writes the data root's extracted/ and quits. if (_spec.ExtractInstall is { } extractInstall) { @@ -708,9 +722,12 @@ public override void _Ready() var shadowQuality = Utils.ShadowQualitySetting.Resolve(_spec.ShadowQuality, Utils.ShadowQualitySetting.SavedWord(_spec.Det), Config.GetString(Utils.ShadowQualitySetting.Key, shadowFallback), shadowFallback); + // Before any session builds, since the ocean's build reads the word. + var waterQuality = Utils.WaterQualitySetting.ResolveForLaunch(_spec.WaterQuality, + Utils.WaterQualitySetting.SavedWord(_spec.Det), _spec.Det); string graphicsWord = graphicsEnhanced ? "enhanced" : "original"; string clamped = renderScale.Clamped ? " clamped_by=fsr2" : string.Empty; - Log.Info("world", $"graphics mode: {Utils.GraphicsMode.Key}={graphicsWord} render_scale={renderScale.Word}% source={Utils.RenderScaleSetting.Lookup.SourceName(renderScale.Source)}{clamped} anti_aliasing={antiAliasing.Word} aa_source={Utils.AntiAliasingSetting.Lookup.SourceName(antiAliasing.Source)} shadow_quality={shadowQuality.Word} shadow_source={Utils.ShadowQualitySetting.Lookup.SourceName(shadowQuality.Source)} view_distance={viewDistance.Word} view_source={Utils.ViewDistance.Lookup.SourceName(viewDistance.Source)}"); + Log.Info("world", $"graphics mode: {Utils.GraphicsMode.Key}={graphicsWord} render_scale={renderScale.Word}% source={Utils.RenderScaleSetting.Lookup.SourceName(renderScale.Source)}{clamped} anti_aliasing={antiAliasing.Word} aa_source={Utils.AntiAliasingSetting.Lookup.SourceName(antiAliasing.Source)} shadow_quality={shadowQuality.Word} shadow_source={Utils.ShadowQualitySetting.Lookup.SourceName(shadowQuality.Source)} view_distance={viewDistance.Word} view_source={Utils.ViewDistance.Lookup.SourceName(viewDistance.Source)} water_quality={waterQuality.Word} water_source={Utils.WaterQualitySetting.Lookup.SourceName(waterQuality.Source)}"); // The window's own viewport takes the render flags here, before any scene builds. The // three SubViewports take them at construction. Utils.ViewportQuality.Apply(GetViewport()); @@ -728,6 +745,7 @@ public override void _Ready() // Registered before the --dump-* branches below, which build materials of their own: // registering after them left every dump run emitting a missing-global error. ShaderTime.RegisterGlobal(); + Effects.Ocean.RegisterGlobal(); // --dump-markers: a pure-data report, print the marker rig tables and quit. Runs // whether or not a content arg was given; --headless makes it windowless. Each dump @@ -1626,6 +1644,39 @@ private static void WarnBotsLeftOut(int left, string bots, string people) } } + // --synthetic-data replaces the data root, whatever it held, with a tree written into scratch. + // The switch alone opts in, and a synthetic tree is named in the log on every run that reads one. + // ⚠ Do not fall back to the resolved root when the tree fails; see _syntheticError. + private void ResolveSyntheticData() + { + if (!_spec.SyntheticData) + { + if (Tooling.SyntheticData.Marks(_dataRoot)) + { + Log.Warn("core", $"SYNTHETIC DATA: the data root {_dataRoot} holds an invented test tree, not an extraction; every asset this run reads is invented"); + } + return; + } + string replaced = _dataRoot; + bool installThere = !UI.Screens.NoGameDataScreen.Missing(replaced); + _dataRoot = Tooling.SyntheticData.ScratchRoot(_repoRoot); + try + { + Tooling.SyntheticData.Build(Tooling.SyntheticData.FixturesUnder(_repoRoot), _dataRoot, UI.Menu.Original.SyntheticShell.TreeFamilies); + } + catch (System.Exception e) + { + // Every failure alike, a malformed record included: each one leaves the tree unusable. + _syntheticError = $"{e.GetType().Name}: {e.Message}"; + return; + } + Flight.Weapons.StockLoadouts.Supplement = + Tooling.SyntheticPlane.LoadoutsUnder(Tooling.SyntheticData.FixturesUnder(_repoRoot)); + SessionSpec.DefaultPlane = Tooling.SyntheticPlane.Plane; + string families = string.Join(",", System.Linq.Enumerable.Select(UI.Menu.Original.SyntheticShell.TreeFamilies, f => f.Name)); + Log.Warn("core", $"SYNTHETIC DATA: --synthetic-data reads the invented tree {_dataRoot} (families {families}) in place of the data root {replaced}{(installThere ? ", whose extraction this run does not read" : "")}"); + } + // Runs on a worker thread, since the extraction must not hold the main thread. The per-frame // callbacks are switched off until the quit, because _Ready returned before building what they read. private void StartHeadlessExtraction(string install) @@ -2653,6 +2704,7 @@ private void PersistOptions(OptionsApplyExit applied) options.RenderScale = applied.RenderScale; options.AntiAliasing = applied.AntiAliasing; options.ShadowQuality = applied.ShadowQuality; + options.WaterQuality = applied.WaterQuality; options.AudioMaster = applied.AudioMaster; options.AudioMusic = applied.AudioMusic; options.AudioEffects = applied.AudioEffects; @@ -2679,14 +2731,20 @@ private void PersistOptions(OptionsApplyExit applied) var shadowQuality = Utils.ShadowQualitySetting.Resolve(_spec.ShadowQuality, applied.ShadowQuality, Config.GetString(Utils.ShadowQualitySetting.Key, shadowFallback), shadowFallback); Log.Info("world", $"shadow quality applied: {shadowQuality.Word} source={Utils.ShadowQualitySetting.Lookup.SourceName(shadowQuality.Source)}"); - // A mode switch dresses the sun at the new level itself; otherwise the level alone moves. + // The same for the sea; --water-quality still beats the saved word. + var waterQuality = Utils.WaterQualitySetting.ResolveForLaunch(_spec.WaterQuality, applied.WaterQuality, _spec.Det); + Log.Info("world", $"water quality applied: {waterQuality.Word} source={Utils.WaterQualitySetting.Lookup.SourceName(waterQuality.Source)}"); + // A mode switch dresses the sun at the new level and follows the sea itself; otherwise the + // level and the sea alone move. if (GraphicsMode.TryParse(applied.Graphics, out bool enhanced) && enhanced != GraphicsMode.Enhanced) { RequestGraphicsSwitch(enhanced, "options", save: false); } - else if (IsInstanceValid(_sun)) + else { - ApplyShadowQuality(_sun); + if (IsInstanceValid(_sun)) + ApplyShadowQuality(_sun); + EnhancedLook.ApplyWaterQuality(_session); } // The render scale and the anti-aliasing method reach every 3D viewport now as well. @@ -3526,10 +3584,12 @@ private void ReportPerf(double delta, in FrameCounters counters) _perfPrims += counters.Prims; _perfNodes += counters.Nodes; _perfMem += counters.MemBytes; - var (discs, discDraws, discShadowDraws) = Flight.Camera.SpyglassView.Census(); + var (discs, discDraws, discShadowDraws, discPrims, discGpuMs) = Flight.Camera.SpyglassView.Census(); _perfDiscs += discs; _perfDiscDraws += discDraws; _perfDiscShadowDraws += discShadowDraws; + _perfDiscPrims += discPrims; + _perfDiscGpu += discGpuMs; if (_perfFrames < PerfWindowFrames) { return; @@ -3594,10 +3654,10 @@ private void ReportPerf(double delta, in FrameCounters counters) // The discs' own line, said only while one rendered in the window. Each is a viewport of its // own, which gpu_ms leaves out and draws folds into the frame's total (verification PERF-47). if (_perfDiscs > 0) - Log.Info("perf", $"spyglass sim_frame={simFrame} discs={_perfDiscs / n:0.00} disc_draws={_perfDiscDraws / n:0.0} disc_shadow_draws={_perfDiscShadowDraws / n:0.0}"); + Log.Info("perf", $"spyglass sim_frame={simFrame} discs={_perfDiscs / n:0.00} disc_draws={_perfDiscDraws / n:0.0} disc_shadow_draws={_perfDiscShadowDraws / n:0.0} disc_prims={_perfDiscPrims / n:0} disc_gpu_ms={_perfDiscGpu / n:0.000}"); _perfClock = 0; _perfFrames = 0; _perfProcess = _perfGpu = _perfCpuRender = _perfPhysics = _perfSetup = 0; _perfDraws = _perfPrims = _perfNodes = _perfMem = 0; - _perfDiscs = _perfDiscDraws = _perfDiscShadowDraws = 0; + _perfDiscs = _perfDiscDraws = _perfDiscShadowDraws = _perfDiscPrims = _perfDiscGpu = 0; } } @@ -3716,4 +3776,9 @@ public sealed class LauncherContext /// outlives every session. Null when the sound archive or the sound definitions would not /// load, which leaves the game silent rather than refusing to launch. public MusicPlayer? Music { get; init; } + + /// A decode store shared with other builds in this process, or null to decode afresh. + /// The test harness hands its own in, so a suite's sessions decode a chapter once. A launch + /// leaves it null, so a session retains nothing of a chapter it has left. + public Mech3.DecodeCache? Decode { get; init; } } diff --git a/CSVM/src/Launch/ObjectiveReadouts.cs b/CSVM/src/Launch/ObjectiveReadouts.cs index 2217adbde..18aba870c 100644 --- a/CSVM/src/Launch/ObjectiveReadouts.cs +++ b/CSVM/src/Launch/ObjectiveReadouts.cs @@ -106,6 +106,17 @@ public static void Build(BuildState state, Inputs inputs) int modeFlagged = modeSites.Count(c => c.Source is ObjectiveSite { Objective: true }); Log.Info("core", $"{spec.Chapter}/{spec.Mission}: {modeFlagged} objective site(s) and {modeSites.Count - modeFlagged} other-target site(s) from the mission's own targets.zrd"); } + else if (spec.MissionType == DogfightMissionType.CaptureTheFlag && inputs.StageArena is { } arena) + { + // The empty stage's arena ships no targets.zrd and has no world index. It flags its own + // flag markers and finds them among its own nodes. The flag runtime labels them. + var sites = new ObjectiveSites(Messages.Load(state.MessagesPath), + MissionTargets.Objectives(EmptyStage.ArenaTargets), key => EmptyStage.ArenaNode(arena, key)); + var dogfight = inputs.Dogfight; + sites.Sides = key => dogfight?.SideOf(key); + inputs.Roster.SetTargetObjectives(into => sites.Collect(into)); + Log.Info("core", $"stage empty: {EmptyStage.ArenaTargets.Count} flag marker site(s) from the match arena"); + } } /// What the readouts are built over, each an output of an earlier build step. @@ -125,5 +136,7 @@ internal sealed class Inputs // A stunt run binds the targets channel per pane instead. public bool Stunting; public VersusDirector? Dogfight; + // The empty stage's match arena, null on a chapter. + public Godot.Node3D? StageArena; } } diff --git a/CSVM/src/Mech3/Anim/NameResolver.cs b/CSVM/src/Mech3/Anim/NameResolver.cs index e7b462e31..a8e9b6407 100644 --- a/CSVM/src/Mech3/Anim/NameResolver.cs +++ b/CSVM/src/Mech3/Anim/NameResolver.cs @@ -47,7 +47,7 @@ public sealed class NameResolver /// Called for each node a name query hands out. That is a answer /// as it is computed or extended, never a memo hit, and every result. A node - /// may arrive more than once. + /// may arrive more than once. Nothing inside calls it. public Action? Claimed; private const int CensusCap = 12; @@ -113,6 +113,10 @@ public sealed class NameResolver private bool _censusOpen; + // Above zero while a Peek runs. FindAll then neither fires Claimed nor writes its memo. A memo + // written unclaimed would answer the later real query as a hit, so its nodes would never be claimed. + private int _peeking; + private int _censusAnchored, _censusNarrowed, _censusLifted, _censusSuppressed, _censusUnanchored, _censusMissing; /// supplies the middle tier: a definition's own template @@ -354,6 +358,7 @@ public List FindAll(string pattern, TNode? scope) return hit.Result; } + bool peek = _peeking > 0; // ⚠ Copy before growing: a caller may still hold the list an earlier query returned. var match = Matcher(pattern); var result = cached ? hit.Result : new List(); @@ -379,13 +384,47 @@ public List FindAll(string pattern, TNode? scope) copied = true; } result.Add(row.Node); - Claimed?.Invoke(row.Node); + if (!peek) + { + Claimed?.Invoke(row.Node); + } } } - _findCache[key] = new FindEntry(result, _index.Count); + if (!peek) + { + _findCache[key] = new FindEntry(result, _index.Count); + } return result; } + /// Runs as a read-only question: every + /// inside it, the owner's hooks included, reads the memo but neither writes it nor fires + /// . A later real query then computes and claims as if the peek never ran. + public T Peek(Func query) + { + ArgumentNullException.ThrowIfNull(query); + _peeking++; + try + { + return query(); + } + finally + { + _peeking--; + } + } + + /// as a read-only question, inside : the same + /// anchors, with no claim, no census entry and no root-lift record. + public List PeekAnchors(AnimDefinition def) + { + if (string.IsNullOrEmpty(def.Name)) + { + return def.MultiTargets.Count == 0 ? new List() : Peek(() => MultiTargetAnchors(def)); + } + return Peek(() => ComputeAnchors(def).Anchors); + } + /// Resolves a single node name for one definition, the compiled symbol table first /// (), then the scoped tier chain. A name the symbol table claims /// but binds to nothing (index not built) still falls through to : @@ -409,29 +448,20 @@ public List FindAll(string pattern, TNode? scope) /// compose it differently. Callers must treat the result as read-only. public List ResolveScoped(IReadOnlyList path, AnimDefinition def, TNode? anchor) { - if (anchor == null || !_isLive(anchor)) + var found = ScopedTiers(path, def, anchor, out bool refused); + if (refused) { - return AdmissibleStaging(ResolvePath(path, null, localOnly: true), def, null); - } - var found = AdmissibleStaging(ResolvePath(path, anchor, localOnly: true), def, anchor); - if (found.Count == 0) - { - found = ResolveInOwnRoot(path, def, anchor); - } - if (found.Count == 0 && !def.LocalNodesOnly) - { - if (RefusesGlobalTier(def, path)) - { - GlobalTierRefused++; - } - else - { - found = AdmissibleStaging(ResolvePath(path, null, localOnly: true), def, anchor); - } + GlobalTierRefused++; } return found; } + /// for a read-only query: the same tiers in the same order, + /// inside and without the tally. Callers + /// must treat the result as read-only. + public List PeekScoped(IReadOnlyList path, AnimDefinition def, TNode? anchor) => + Peek(() => ScopedTiers(path, def, anchor, out _)); + /// Whether refuses this write the global tier. It does when /// the world holds several instances of the definition and the name carries no wildcard. Several /// instances means a NAME wildcard or an root lift. Such a plain name @@ -682,6 +712,33 @@ private static string Sample(List shown, int total) => return null; } + // The tier chain both scoped forms share; refused reports a global tier RefusesGlobalTier denied. + private List ScopedTiers(IReadOnlyList path, AnimDefinition def, TNode? anchor, out bool refused) + { + refused = false; + if (anchor == null || !_isLive(anchor)) + { + return AdmissibleStaging(ResolvePath(path, null, localOnly: true), def, null); + } + var found = AdmissibleStaging(ResolvePath(path, anchor, localOnly: true), def, anchor); + if (found.Count == 0) + { + found = ResolveInOwnRoot(path, def, anchor); + } + if (found.Count == 0 && !def.LocalNodesOnly) + { + if (RefusesGlobalTier(def, path)) + { + refused = true; + } + else + { + found = AdmissibleStaging(ResolvePath(path, null, localOnly: true), def, anchor); + } + } + return found; + } + // A parent->child NAME path: the first element within scope (falling back to the whole index // when that misses and localOnly is false), then each further element inside the previous // matches. ⚠ Private on purpose: this is ResolveScoped's only route, keeping call-site variants diff --git a/CSVM/src/Mech3/Anim/PoseChannel.cs b/CSVM/src/Mech3/Anim/PoseChannel.cs index 220c5c6bf..fa6d492e4 100644 --- a/CSVM/src/Mech3/Anim/PoseChannel.cs +++ b/CSVM/src/Mech3/Anim/PoseChannel.cs @@ -335,15 +335,8 @@ internal int HandleMotionSiScriptAllNames(AnimEvent ev, AnimDefinition def, Node { duration = 0f; int applied = 0; - foreach (var motion in ev.Data.Objects("motions")) + foreach (var one in ev.AllNamesMotions()) { - var one = new AnimEvent - { - Kind = "ObjectMotionSiScript", - Data = motion, - StartOffset = ev.StartOffset, - StartTime = ev.StartTime, - }; applied += HandleMotionSiScript(one, def, anchor, instant, out float oneDuration); duration = Mathf.Max(duration, oneDuration); } @@ -358,7 +351,7 @@ internal int HandleMotionSiScriptAllNames(AnimEvent ev, AnimDefinition def, Node /// Marks a node as having just landed by contact, see /// . Called on the dispatch path, and by the - /// ground-contact suite, which drives a motion set directly. + /// ground-contact-core suite, which drives a motion set directly. internal void MarkLandingResume(Node3D target) => _resumeFromLanding.Add(target); // OBJECT_OPACITY_STATE applies to the whole subtree per instance, never as a material edit. diff --git a/CSVM/src/Mech3/AnimRuntime.cs b/CSVM/src/Mech3/AnimRuntime.cs index 82e623d96..b7ad86d8a 100644 --- a/CSVM/src/Mech3/AnimRuntime.cs +++ b/CSVM/src/Mech3/AnimRuntime.cs @@ -447,6 +447,10 @@ public sealed partial class AnimRuntime : Node, ISequenceHost // gameplay distance, and the defs that shrink away hide the node anyway. private const float MinPoseScale = 1e-3f; + // The bind census's cause for a shared definition's name its own instance does not carry, the + // one miss Targets also logs. + private const string OutsideOwnInstance = "outside-own-instance"; + // Name resolution, the index, wildcard matcher, memoized FindAll, and the three-tier scope // chain, lives in NameResolver.cs; identity is instance id, since Godot object equality is // unreliable inside a dictionary/tuple key across proxy instances of the same native node. @@ -976,6 +980,32 @@ public static AnimRuntime ForCrashRig(TemplateStage stage, int seed, /// inspect tool asks the engine instead of re-implementing the matcher. Read-only. public IReadOnlyList FindNodes(string pattern, Node3D? scope = null) => FindAll(pattern, scope); + /// The world nodes the dispatch writes when it plays of + /// . It asks on each instance the bind gives the definition: one per + /// anchor, or one global instance when placeless, as starts it. The rule is + /// the dispatch's own, an ALL_NAMES event per record. Read-only: nothing is counted, logged or + /// claimed and no name answer is memoized, so the world merge is the same whether this ran or + /// not. A node may appear more than once. + public List TargetsOf(AnimDefinition def, IEnumerable events) + { + var anchors = _resolver.PeekAnchors(def); + if (anchors.Count == 0) + anchors.Add(null); + return _resolver.Peek(() => + { + var nodes = new List(); + foreach (var ev in events) + { + foreach (var one in ev.Kind == AnimDefinition.AllNamesKind ? ev.AllNamesMotions() : new[] { ev }) + { + foreach (var anchor in anchors) + nodes.AddRange(ResolveTargets(one, def, anchor, peek: true, out _)); + } + } + return nodes; + }); + } + /// Every live node a name query has handed out so far. Every node a definition's symbol /// table or node prerequisite binds is added, run or not. A renderer that copies static nodes /// leaves these subtrees alone and follows after. @@ -2197,11 +2227,10 @@ internal Transform3D RestOf(Node3D node) /// it. A respawn re-poses only these, and leaves nodes other animators drive alone. internal bool HasPosed(Node3D node) => _rest.ContainsKey(node); - // Thin forwards into the pose family, kept here because their callers name this runtime: - // MotionRuntime.Create reads the landing-resume mark as `rt.ConsumeLandingResume`, the - // `ground-contact` suite arms it through `runtime.MarkLandingResume`, and OpacityFade and the - // zeppelin dormancy pose write through `rt.SetSubtreeOpacity`. The state and the bodies live - // in PoseChannel. + // Thin forwards into the pose family, kept here because their callers name this runtime. + // MotionRuntime.Create reads the landing-resume mark, and the ground-contact-core suite arms it. + // OpacityFade and the zeppelin dormancy pose write through SetSubtreeOpacity. The state and the + // bodies live in PoseChannel. internal bool ConsumeLandingResume(Node3D target) => Pose.ConsumeLandingResume(target); internal void MarkLandingResume(Node3D target) => Pose.MarkLandingResume(target); @@ -4752,11 +4781,29 @@ private Node3D DamageNodeOf(AnimDefinition def, Node3D anchor) return found.Count > 0 ? found[0] : anchor; } - // The world nodes one event targets. Reader-sourced events may carry a parent→child path; - // compiled events name a single node (under "node" or "name", which upstream spells - // inconsistently per event type). + // The world nodes one event targets, as the dispatch writes them. A miss is booked here: the + // unresolved-op count, the bind census and the instance-miss log line. private List Targets(AnimEvent ev, AnimDefinition def, Node3D? anchor) { + var targets = ResolveTargets(ev, def, anchor, peek: false, out var miss); + if (miss is not { } m) + return targets; + _opsUnresolved++; + _resolver.RecordMissingTarget(def, m.Named, m.Why); + // The original's own no-op, logged: a shared definition's name that its instance does + // not carry must not be searched for in the world (docs/org/sequences.md). + if (m.Why == OutsideOwnInstance && _instanceMissesLogged++ < 12) + Log.Info("anim", $"anim: '{def.AnimName ?? def.Name}' names '{m.Named}', which its own instance under '{NameOf(anchor!)}' does not carry; the write is a no-op"); + return targets; + } + + // The resolution half of Targets, free of its books, so TargetsOf can ask the same question. + // Reader-sourced events may carry a parent→child path; compiled events name a single node + // (under "node" or "name", which upstream spells inconsistently per event type). A peek also + // leaves the resolver's global-tier tally alone. A miss is reported, never booked here. + private List ResolveTargets(AnimEvent ev, AnimDefinition def, Node3D? anchor, bool peek, out TargetMiss? miss) + { + miss = null; // ⚠ Resolve the self-reference sentinels here (IsSelfNodeRef). No node is named that and // the symbol table does not bind it, so without this a self-referencing event resolves to // nothing and is silently dropped, and a shot-down hull never falls. @@ -4765,8 +4812,7 @@ private List Targets(AnimEvent ev, AnimDefinition def, Node3D? anchor) var host = InputNodeOf(def, anchor) ?? anchor; if (host != null) return new List { host }; - _opsUnresolved++; - _resolver.RecordMissingTarget(def, selfRef, "self-ref-no-anchor"); + miss = new TargetMiss(selfRef, "self-ref-no-anchor"); return new List(); } @@ -4793,8 +4839,7 @@ private List Targets(AnimEvent ev, AnimDefinition def, Node3D? anchor) // animates, and the rig the mission spawned carries the same names. if (VehiclePart(anchor, refName) is { } part) return new List { part }; - _opsUnresolved++; - _resolver.RecordMissingTarget(def, refName, "index-not-built"); + miss = new TargetMiss(refName, "index-not-built"); return new List(); } } @@ -4812,17 +4857,11 @@ private List Targets(AnimEvent ev, AnimDefinition def, Node3D? anchor) } if (path.Count == 0) return new List(); - var targets = ResolveScoped(path, def, anchor); + var targets = peek ? _resolver.PeekScoped(path, def, anchor) : ResolveScoped(path, def, anchor); if (targets.Count == 0) { - _opsUnresolved++; - string named = string.Join("/", path); bool confined = anchor != null && _resolver.RefusesGlobalTier(def, path); - _resolver.RecordMissingTarget(def, named, confined ? "outside-own-instance" : "name-no-match"); - // The original's own no-op, logged: a shared definition's name that its instance does - // not carry must not be searched for in the world (docs/org/sequences.md). - if (confined && _instanceMissesLogged++ < 12) - Log.Info("anim", $"anim: '{def.AnimName ?? def.Name}' names '{named}', which its own instance under '{NameOf(anchor!)}' does not carry; the write is a no-op"); + miss = new TargetMiss(string.Join("/", path), confined ? OutsideOwnInstance : "name-no-match"); } return targets; } @@ -4869,6 +4908,9 @@ private bool HasHiddenAncestor(Node3D node) return false; } + // What Targets books for an event it resolved to nothing: the name and the census cause. + private readonly record struct TargetMiss(string Named, string Why); + // Godot object identity is by native pointer, not the inherited Equals: two managed proxies // can wrap the same native node, so NameResolver's dictionary/tuple keys need this rather than // trusting Node3D's own equality. diff --git a/CSVM/src/Mech3/CampaignSequence.cs b/CSVM/src/Mech3/CampaignSequence.cs index 6b1993f26..246580f66 100644 --- a/CSVM/src/Mech3/CampaignSequence.cs +++ b/CSVM/src/Mech3/CampaignSequence.cs @@ -50,12 +50,15 @@ public static class CampaignSequence /// last record landing on ordinal 24. public const int MissionCount = 24; + /// The reader file reads from the shared zrdr scope. + public const string FileName = "cm_sequence.json"; + /// Loads the sequence from a shared zrdr scope (extracted/zrdr.zip or its /// unpacked sibling), in file order. The reader is one outer element holding the records. public static List Load(string zrdrPath) { var missions = new List(); - var root = Zrdr.LoadFile(zrdrPath, "cm_sequence.json"); + var root = Zrdr.LoadFile(zrdrPath, FileName); if (root.Count == 0 || root[0] is not List records) { return missions; diff --git a/CSVM/src/Mech3/CompiledAnim.cs b/CSVM/src/Mech3/CompiledAnim.cs index fbe803d4c..eb06f0452 100644 --- a/CSVM/src/Mech3/CompiledAnim.cs +++ b/CSVM/src/Mech3/CompiledAnim.cs @@ -463,6 +463,25 @@ public sealed class AnimEvent ev.WaitsForCompletion = ev.Kind == "CallAnimation" && ev.Data.Has("wait_for_completion"); return ev; } + + /// The single-node ObjectMotionSiScript events an + /// event plays at once, one per decoded record and + /// scheduled as this event; none for any other kind. + public IEnumerable AllNamesMotions() + { + if (Kind != AnimDefinition.AllNamesKind) + yield break; + foreach (var motion in Data.Objects("motions")) + { + yield return new AnimEvent + { + Kind = "ObjectMotionSiScript", + Data = motion, + StartOffset = StartOffset, + StartTime = StartTime, + }; + } + } } /// diff --git a/CSVM/src/Mech3/EmptyStage.cs b/CSVM/src/Mech3/EmptyStage.cs index 155286b65..1c1e605c8 100644 --- a/CSVM/src/Mech3/EmptyStage.cs +++ b/CSVM/src/Mech3/EmptyStage.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using CSVM.Utils; using Godot; @@ -38,6 +39,41 @@ public sealed class EmptyStage /// which every airframe in the table turns inside. public const int PatrolRingNodes = 8; + /// The Dogfight spawn ring's radius, metres. Two seats on opposite entries open + /// 1200 m apart and closing, so a match's first pass comes within seconds. + public const float SpawnRingRadius = 600f; + + /// Entries on the Dogfight spawn ring: one whole 16-entry net.zrd block + /// (docs/formats/net-spawns.md), so every seat a match admits opens on its own entry. A power of + /// two, which the spread order in needs. + public const int SpawnRingEntries = 16; + + /// How far right of the origin every ring entry's nose is aimed, degrees. Opposite + /// seats then pass about 208 m abeam rather than ramming nose to nose hands-off. + public const float SpawnRingSkewDeg = 10f; + + /// Team blocks after the free-for-all block in : one per lobby + /// team, four, the count of a map that supports four-team play (docs/formats/net-spawns.md). + /// + public const int TeamBlockCount = 4; + + /// How far each team's base stands from the origin, metres. Well outside + /// , so no team opening shares ground with a free-for-all entry. + /// + public const float TeamBaseRadius = 1500f; + + /// Metres between neighbouring positions of a team block's square, and between the + /// rungs of its altitude ladder. + public const float TeamBlockSpacing = 100f; + + /// The bases the match arena stands, team 1's and team 2's. Each is a ctf_n + /// base, a cs_flag_n flag with its carried twin and a rearm_node_n. + public const int ArenaBases = 2; + + /// How far a base's rearm node stands from its flag toward the origin, and how high, + /// metres. Clear of the flag's reach, so a pilot at one is not at the other. + public static readonly Vector3 RearmOffset = new(0f, 50f, 300f); + /// The freecam eye when nothing placed it: back and above the origin, looking at it. public static readonly Vector3 CameraPos = new(0f, 120f, 300f); @@ -66,6 +102,33 @@ private EmptyStage() { } /// no chapter assets present. public static AiNet PatrolNet { get; } = BuildPatrolNet(); + /// The stage's own Dogfight spawn table, one free-for-all block of net.zrd + /// records (position, heading in degrees). Its entries sit on a + /// ring at , each aimed + /// right of the origin. Every leading run is spread: entries 0 and + /// 1 are opposite, 0 to 3 a compass cross, 0 to 7 its eight points. ⚠ Built in code: a match here + /// must boot with no multiplayer map present. + public static IReadOnlyList<(Vector3 Position, float HeadingDeg)> SpawnRing { get; } = BuildSpawnRing(); + + /// The stage's whole Dogfight table, the shape a team match walks: + /// as block 0, then team blocks. Block n is + /// a staging stack at team n's base (), facing the origin. Its square of + /// four positions repeats on four rungs, all apart. + /// ⚠ Built in code: a match here must boot with no multiplayer map present. + public static IReadOnlyList<(Vector3 Position, float HeadingDeg)> SpawnTable { get; } = BuildSpawnTable(); + + /// The match arena's named nodes, (name, position). Each of the + /// bases stands its ctf_n and cs_flag_n on the ground + /// with the carried cs_flg_lightn beside them. Its rearm_node_n stands at + /// . The names are the ones the flag and rearm runtimes and the flag + /// markers look a mission world up by. + public static IReadOnlyList<(string Name, Vector3 Position)> ArenaNodes { get; } = BuildArenaNodes(); + + /// The arena's flag marker keys, each base's ctf_n, cs_flag_n and + /// cs_flg_lightn. They are the three entries a map's targets.zrd lists per flag + /// for the flag runtime to label by side (docs/org/multiplayer-ctf.md "Markers"). + public static IReadOnlyList ArenaTargets { get; } = BuildArenaTargets(); + /// The stage subtree, the caller adds it to the session root exactly as it adds a /// built world. public Node3D Root { get; private set; } = null!; @@ -74,9 +137,14 @@ private EmptyStage() { } public int ColliderCount { get; private set; } + /// The match arena's subtree, holding , or null when the + /// stage was built without one. + public Node3D? Arena { get; private set; } + /// Attach the ground collider (true in flight, so weapons and the /// airframe have something to hit; false for a plane-less look at the stage). - public static EmptyStage Build(bool collision) + /// Stand on the stage, for a Dogfight. + public static EmptyStage Build(bool collision, bool arena = false) { var stage = new EmptyStage(); var root = new Node3D { Name = "empty_stage" }; @@ -118,9 +186,46 @@ public static EmptyStage Build(bool collision) } Log.Info("world", $"stage empty: {HalfExtent * 2f / 1000f:0.#} km ground grid, cell={CellMetres:0} m, collision={(collision ? "on" : "off")}"); + if (arena) + { + stage.Arena = BuildArena(); + root.AddChild(stage.Arena); + Log.Info("world", $"stage empty: match arena, {ArenaBases} bases with flags and rearm nodes, {TeamBlockCount} team blocks"); + } + return stage; } + /// Team 's base on the ground, out. + /// Team 1 stands due north of the origin, team 2 due south, team 3 east and team 4 west. + public static Vector3 TeamBase(int team) + { + float angle = Mathf.DegToRad(TeamBearing(team)); + return new Vector3(Mathf.Sin(angle) * TeamBaseRadius, 0f, -Mathf.Cos(angle) * TeamBaseRadius); + } + + /// The arena node carrying as its gamez name, the lookup a + /// mission world answers through its node index, or null. Case-insensitive, as that index is. + /// + public static Node3D? ArenaNode(Node3D? arena, string name) + { + if (arena == null) + { + return null; + } + + foreach (var child in arena.GetChildren()) + { + if (child is Node3D node && node.HasMeta(AnimRuntime.NameMeta) + && node.GetMeta(AnimRuntime.NameMeta).AsString().Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return node; + } + } + + return null; + } + /// when names it, else null, so a /// caller resolves the built-in before it falls back to a chapter's nets. Case-insensitive, the /// comparison makes. @@ -153,6 +258,106 @@ private static AiNet BuildPatrolNet() }; } + // The ring is the patrol net's frame: slot 0 due north, the angle running clockwise seen from + // above. Entry i takes the slot whose index is i's bits reversed, so each leading run halves + // the gaps. A heading yaws the -Z nose left, so 180 minus the bearing faces the origin. + private static (Vector3 Position, float HeadingDeg)[] BuildSpawnRing() + { + int bits = 0; + while ((1 << bits) < SpawnRingEntries) + { + bits++; + } + var ring = new (Vector3 Position, float HeadingDeg)[SpawnRingEntries]; + for (int i = 0; i < SpawnRingEntries; i++) + { + int slot = 0; + for (int b = 0; b < bits; b++) + { + if ((i & (1 << b)) != 0) + { + slot |= 1 << (bits - 1 - b); + } + } + float bearing = 360f * slot / SpawnRingEntries; + float angle = Mathf.DegToRad(bearing); + ring[i] = (new Vector3(Mathf.Sin(angle) * SpawnRingRadius, SpawnAltitude, + -Mathf.Cos(angle) * SpawnRingRadius), Mathf.Wrap(180f - bearing - SpawnRingSkewDeg, -180f, 180f)); + } + return ring; + } + + // Opposing pairs first, so a two-team match faces across the origin. + private static float TeamBearing(int team) => team switch { 1 => 0f, 2 => 180f, 3 => 90f, _ => 270f }; + + // Bare nodes carrying the gamez name meta, which is what a lookup by node name reads. The + // carried flag starts hidden, as the flag runtime keeps it while the flag is home. + private static Node3D BuildArena() + { + var arena = new Node3D { Name = "arena" }; + foreach (var (name, position) in ArenaNodes) + { + var node = new Node3D { Name = name, Position = position }; + node.SetMeta(AnimRuntime.NameMeta, name); + node.Visible = !name.StartsWith("cs_flg_light", StringComparison.Ordinal); + arena.AddChild(node); + } + + return arena; + } + + private static (string Name, Vector3 Position)[] BuildArenaNodes() + { + var nodes = new List<(string, Vector3)>(); + for (int team = 1; team <= ArenaBases; team++) + { + var home = TeamBase(team); + var inward = new Basis(Vector3.Up, Mathf.DegToRad(-TeamBearing(team))) * RearmOffset; + string n = team.ToString(System.Globalization.CultureInfo.InvariantCulture); + nodes.Add(("ctf_" + n, home)); + nodes.Add(("cs_flag_" + n, home)); + nodes.Add(("cs_flg_light" + n, home)); + nodes.Add(("rearm_node_" + n, home + inward)); + } + + return nodes.ToArray(); + } + + private static string[] BuildArenaTargets() + { + var keys = new List(); + for (int team = 1; team <= ArenaBases; team++) + { + string n = team.ToString(System.Globalization.CultureInfo.InvariantCulture); + keys.Add("ctf_" + n); + keys.Add("cs_flag_" + n); + keys.Add("cs_flg_light" + n); + } + + return keys.ToArray(); + } + + // Entry e of a team block sits at position e % 4 of the square on rung e / 4. A team's first + // seats then open side by side on the lowest rung. + private static (Vector3 Position, float HeadingDeg)[] BuildSpawnTable() + { + var table = new List<(Vector3 Position, float HeadingDeg)>(SpawnRing); + for (int team = 1; team <= TeamBlockCount; team++) + { + var basis = new Basis(Vector3.Up, Mathf.DegToRad(-TeamBearing(team))); + float heading = Mathf.Wrap(180f - TeamBearing(team), -180f, 180f); + for (int entry = 0; entry < SpawnRingEntries; entry++) + { + float across = ((entry % 2) - 0.5f) * TeamBlockSpacing; + float along = (((entry / 2) % 2) - 0.5f) * TeamBlockSpacing; + float rung = SpawnAltitude + ((entry / 4) * TeamBlockSpacing); + table.Add((TeamBase(team) + (basis * new Vector3(across, 0f, along)) + (Vector3.Up * rung), heading)); + } + } + + return table.ToArray(); + } + private static StandardMaterial3D GridMaterial() { int repeats = Mathf.RoundToInt(HalfExtent * 2f / CellMetres); diff --git a/CSVM/src/Mech3/SceneBuilder.cs b/CSVM/src/Mech3/SceneBuilder.cs index b6fc30664..f4d784dfe 100644 --- a/CSVM/src/Mech3/SceneBuilder.cs +++ b/CSVM/src/Mech3/SceneBuilder.cs @@ -56,6 +56,13 @@ public sealed class SceneBuilder // logged, not silently collapsed. public const int ConflictRankCap = 7; + // Depth-bias fraction per priority level. A polygon is pulled toward the eye by priority × this + // fraction of its view distance: same projected position, nearer depth. That replicates the + // original's coplanar layering (terrain patches, decals, plane logos) without z-fighting. + // TUNE: big enough to beat coplanar interpolation noise at 10 km. Small enough that the ±49 + // extremes (cockpit gauges, skydome) stay well under 1% of view distance. + public const float DepthBiasPerLevel = 2e-4f; + /// How near the drawing camera must stand to the eye /// carries, in metres, to draw the photograph's fill. It separates the photograph's camera /// from every pane's, which never stands two and a half chase distances ahead of the nose. @@ -184,6 +191,8 @@ public sealed class SceneBuilder "#include \"res://shaders/csky_mip_bias.gdshaderinc\""; internal const string FacadeInclude = "#include \"res://shaders/csky_facade.gdshaderinc\""; + internal const string OceanInclude = + "#include \"res://shaders/csky_ocean.gdshaderinc\""; /// The animation runtime's OBJECT_OPACITY_STATE translucency, a per-instance /// multiplier on ALPHA, because materials are cached and this changes at runtime. Declared in @@ -225,6 +234,15 @@ public sealed class SceneBuilder internal const string CoverageLines = " ALPHA_ANTIALIASING_EDGE = 0.0;\n" + " ALPHA_TEXTURE_COORDINATE = UV * vec2(textureSize(albedo_tex, 0));"; + // Enhanced mode only: what a surface calls water gets instead of + // the matte world values, so screen-space reflection has a glossy surface to march against. + // TUNE, judged at the controls: roughness sets how far a reflection smears, specular how much + // of it survives at a glancing angle. Roughness is also what quiets a per-texel reflection + // flicker, because it blurs a lost ray across its neighbours instead of dimming the water. + // The wave ocean shades toward both where it calms (Effects/Ocean.cs). + internal const float WaterRoughness = 0.25f; + internal const float WaterSpecular = 0.5f; + /// Multiplies every depth bias this builder emits. Each is a fraction of VIEW /// DISTANCE, so a subtree mounted at a scale other than 1 has all of them compressed by that /// factor while the renderer's depth noise floor stays put; a caller mounting one passes the @@ -314,13 +332,6 @@ void fragment() { // The source UVs are exact 0.0/1.0 at the fold, so this only absorbs float noise. private const float UvEpsilon = 1e-6f; - // Depth-bias fraction per priority level. Polygons are pulled toward the eye by - // priority × this fraction of their view distance, same projected position, nearer - // depth, replicating the original's coplanar-decal layering (terrain patches, - // road/shadow decals, plane logos) without z-fighting at any range. - // TUNE: big enough to beat coplanar interpolation noise at 10 km, small enough that - // the ±49 extremes (cockpit gauges, skydome) stay well under 1% of view distance. - private const float DepthBiasPerLevel = 2e-4f; // The within-mesh equal-priority tie-break, reproducing the original's draw order (drawn later // = on top): surface rank is the first-occurrence order of the (material, priority) group in // the polygon list, so a tile's roads and shoreline blends land over its base grass. @@ -357,13 +368,6 @@ void fragment() { // decides how far it spreads. ⚠ Only the light-source arms take it; the general `lighting: // false` population is not emissive (docs/org/vertexLighting.md). private const float EmissiveScale = 1.5f; - // Enhanced mode only: what a surface calls water gets instead of - // the matte world values, so screen-space reflection has a glossy surface to march against. - // TUNE, judged at the controls: roughness sets how far a reflection smears, specular how much - // of it survives at a glancing angle. Roughness is also what quiets a per-texel reflection - // flicker, because it blurs a lost ray across its neighbours instead of dimming the water. - private const float WaterRoughness = 0.25f; - private const float WaterSpecular = 0.5f; // What an aircraft surface reflects under the scene lights, which only enhanced mode's aircraft // and a shaded builder without the per-vertex sun term draw under. The original has no // specular term at all (docs/org/vertexLighting.md), so any highlight here is ours. A TUNE @@ -583,6 +587,14 @@ private enum CylAxis { None, Y, X } /// caller can re-resolve and swap them without a rebuild. See . public IReadOnlyList<(ShaderMaterial Material, string TextureName)> TexturedMaterials => _texturedMaterials; + /// The shader condition that the drawing camera is a spyglass disc's, which shows the flat + /// sea: it carries and not the sun's layer. Read in + /// both stages: the ocean's grid collapses on it and the sheet's hide steps back. ⚠ Keep the sun's + /// bit; a pass reporting every layer (a fresh camera, a shadow pass) would read as the disc. ⚠ Keep + /// it a property: a field runs this type's engine statics, which the unit-test host cannot build. + internal static string FlatSeaEye => string.Create(System.Globalization.CultureInfo.InvariantCulture, + $"((CAMERA_VISIBLE_LAYERS & {UI.Boards.SplitScreen.FlatSeaLayer | UI.Boards.SplitScreen.SunLayer}u) == {UI.Boards.SplitScreen.FlatSeaLayer}u)"); + /// Whether a struck collider's node accepts a crater, the original's /// CAN_MODIFY gate read off . public static bool CanModify(Node? collider) => collider != null && collider.HasMeta(CanModifyMeta); @@ -702,6 +714,18 @@ internal static bool UvsWithinUnitSquare(List polys, int pass) => return null; } + /// Whether a water texture is the open-sea base sheet the Enhanced wave ocean + /// (Effects.Ocean) replaces. A coastline blend, surf ring or wake drawn over it is not. + /// ⚠ Match C1's water1 exactly: its water1_trans1/2 coast tiles are opaque land. + internal static bool IsOceanBaseTexture(string texName) + { + if (texName.StartsWith("wtr", StringComparison.OrdinalIgnoreCase)) + return true; + int dot = texName.IndexOf('.'); + var stem = dot < 0 ? texName.AsSpan() : texName.AsSpan(0, dot); + return stem.Equals("water1", StringComparison.OrdinalIgnoreCase); + } + /// A copy of whose alpha is the distance from its corner texel's /// colour. The backdrop goes to 0, a painted halo to a partial value, the figure to 1. An /// opaque sky sprite paints its backdrop in the sky's colour. Keying it lets the sky show up to @@ -1775,7 +1799,8 @@ private Material BuildMaterial(int materialIndex, int priority, int rank, bool n return billboard; } bool water = ClassifySurface(texName) == "water"; - var textured = BiasMaterial(priority, rank, noClutter, doubleSided, tex, null, blend, scissor, scroll, clampUv, lit, fogged, pass, edgeClamp, clutterFade, water); + bool oceanBase = water && !blend && !scissor && IsOceanBaseTexture(texName); + var textured = BiasMaterial(priority, rank, noClutter, doubleSided, tex, null, blend, scissor, scroll, clampUv, lit, fogged, pass, edgeClamp, clutterFade, water, oceanBase); if (keyed != null) { bool scrolls = scroll != Vector2.Zero; @@ -1821,14 +1846,14 @@ private void NoteAlpha(Material mat, bool blend, bool scissor) => // The per-node draw-order term is added at instance level (see BuildSubtree). private ShaderMaterial BiasMaterial(int priority, int rank, bool noClutter, bool doubleSided, ImageTexture? tex, Color? color, bool blend, bool scissor, Vector2 scroll, bool clampUv, bool lit, bool fogged, int pass = 0, - UvClampAxes edgeClamp = UvClampAxes.None, bool clutterFade = false, bool water = false) + UvClampAxes edgeClamp = UvClampAxes.None, bool clutterFade = false, bool water = false, bool oceanBase = false) { // Only a textured surface can scroll its UVs (a Colored material has no sampler). bool scrolls = tex != null && scroll != Vector2.Zero; if (tex == null) edgeClamp = UvClampAxes.None; var mat = ShaderTwins.Follow(new ShaderMaterial(), GetBiasShader(shaded: !_fullbright, textured: tex != null, - blend, scissor, doubleSided, scrolls, clampUv && tex != null, lit, fogged, edgeClamp, clutterFade, water)); + blend, scissor, doubleSided, scrolls, clampUv && tex != null, lit, fogged, edgeClamp, clutterFade, water, oceanBase)); NoteAlpha(mat, blend, scissor); float bias = Mathf.Clamp(priority * DepthBiasPerLevel, -0.05f, 0.05f) + rank * SurfaceRankBias; if (noClutter) @@ -1861,14 +1886,15 @@ private ShaderMaterial BiasMaterial(int priority, int rank, bool noClutter, bool // fogged surface then emits the shader text it always did, free of a mix()'s float rounding. // Key bits: 1-64 the flags, 128 !lit, 256 !fogged, 512/1024 edgeClamp, 2048 clutterFade. // Then 4096 DebugClutterFlag, 16384 water, 32768 sun, 65536 gamma blend, 131072 NoAlphaCoverage, - // 262144 the race ghost. + // 262144 the race ghost, 524288 the hideable base sea. // ⚠ Keep the graphics mode out of the key: each key holds one shader per mode (ShaderTwins). private ModeShader GetBiasShader(bool shaded, bool textured, bool blend, bool scissor, bool doubleSided, bool scroll, bool clampUv, bool lit, bool fogged, UvClampAxes edgeClamp = UvClampAxes.None, - bool clutterFade = false, bool water = false) + bool clutterFade = false, bool water = false, bool oceanBase = false) { // Only a lit world surface can take the water arm, and only a shaded one the sun term. water &= !shaded && lit; + oceanBase &= water; bool sunVertexLit = shaded && _sunVertexLit; bool raceGhost = shaded && RaceGhostShader; // Only a blending surface has an alpha to correct; a scissor compares against a fixed 0.5 @@ -1880,13 +1906,13 @@ private ModeShader GetBiasShader(bool shaded, bool textured, bool blend, bool sc | (scroll ? 32 : 0) | (clampUv ? 64 : 0) | (lit ? 0 : 128) | (fogged ? 0 : 256) | ((int)edgeClamp << 9) | (clutterFade ? 2048 : 0) | (debugClutter ? 4096 : 0) | (water ? 16384 : 0) | (sunVertexLit ? 32768 : 0) | (gammaBlend ? 65536 : 0) - | (noAlphaCoverage ? 131072 : 0) | (raceGhost ? 262144 : 0); + | (noAlphaCoverage ? 131072 : 0) | (raceGhost ? 262144 : 0) | (oceanBase ? 524288 : 0); ShaderTwins.EnsureCurrent(); if (!BiasShaders.TryGetValue(key, out var twins)) { BiasShaders[key] = twins = ShaderTwins.Make(() => BiasShaderCode(shaded, textured, blend, scissor, doubleSided, scroll, clampUv, lit, fogged, edgeClamp, clutterFade, water, sunVertexLit, gammaBlend, - debugClutter, noAlphaCoverage, raceGhost), "world", $"world:{key:x}"); + debugClutter, noAlphaCoverage, raceGhost, oceanBase), "world", $"world:{key:x}"); } return twins; } @@ -1898,7 +1924,7 @@ private ModeShader GetBiasShader(bool shaded, bool textured, bool blend, bool sc // GraphicsMode.Enhanced, so ShaderTwins can write it again under either mode. private static string BiasShaderCode(bool shaded, bool textured, bool blend, bool scissor, bool doubleSided, bool scroll, bool clampUv, bool lit, bool fogged, UvClampAxes edgeClamp, bool clutterFade, bool water, - bool sunVertexLit, bool gammaBlend, bool debugClutter, bool noAlphaCoverage, bool raceGhost) + bool sunVertexLit, bool gammaBlend, bool debugClutter, bool noAlphaCoverage, bool raceGhost, bool oceanBase) { // Enhanced mode only: a world surface authored `lighting: true` shades under the real scene // lights off its decoded normals. `lighting: false` is self-lit by intent and keeps the @@ -1908,6 +1934,8 @@ private static string BiasShaderCode(bool shaded, bool textured, bool blend, boo // ⚠ The water arm exists only inside the lit world arm, so original mode's shader text // cannot move. bool waterLit = worldLit && water; + // The sea-level base sheet steps aside while the wave ocean draws in its place. + bool oceanHide = waterLit && oceanBase; // The original's own aircraft light: unshaded, the per-vertex sun term times the authored // colour, clamped, then the texel (docs/org/vertexLighting.md). No Godot light reaches it. bool sunLit = sunVertexLit && !GraphicsMode.Enhanced; @@ -1955,6 +1983,11 @@ private static string BiasShaderCode(bool shaded, bool textured, bool blend, boo bool pointLit = lit && (fullbright || sunLit); if (pointLit) sb.AppendLine(LightsInclude); + if (oceanHide) + { + sb.AppendLine(OceanInclude); + sb.AppendLine("varying vec3 v_ocean_pos;"); + } if (textured) { // Anisotropic mipmap filtering: the world is viewed at grazing angles from the air, @@ -2007,6 +2040,10 @@ private static string BiasShaderCode(bool shaded, bool textured, bool blend, boo + " VERTEX *= step(0.004, v_clutter_alpha);\n" : ""; string ghostVertex = raceGhost ? RaceGhost.VertexLine + "\n" : ""; + // ⚠ Hide per fragment, never by collapsing vertices. A water ramp rising off the sea (C2, C5) + // would stretch to its model origin. The colliders stay flat. The hide reads the ocean's mask + // at the fragment's world X/Z, so a sheet shown after the bake still draws. + string oceanVertex = oceanHide ? " v_ocean_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;\n" : ""; // Per vertex in world space: the drawing view's sun and the point lights, summed into one // factor on the authored colour, clamped at white. At an armed eye (PhotoEyeParam) the // ambient half is the photograph's fill; `lighting: false` keeps the authored colour. @@ -2033,7 +2070,7 @@ private static string BiasShaderCode(bool shaded, bool textured, bool blend, boo + " }\n"; sb.AppendLine($@" void vertex() {{ -{clutterVertex}{ghostVertex}{sunVertex}{pointVertex} VERTEX = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; +{clutterVertex}{ghostVertex}{oceanVertex}{sunVertex}{pointVertex} VERTEX = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; NORMAL = {normalSign}normalize(MODELVIEW_NORMAL_MATRIX * NORMAL); // Scale toward the eye (the view-space origin): identical projected position, // depth nudged nearer by bias × distance, a scale-invariant polygon offset. @@ -2041,6 +2078,9 @@ void vertex() {{ }} void fragment() {{"); + // A spyglass disc draws no ocean (FlatSeaEye), so the sheet stays whole there. + if (oceanHide) + sb.AppendLine($" if (!{FlatSeaEye} && csky_ocean_hides_sea(v_ocean_pos)) {{ discard; }}"); if (clutterFade) sb.AppendLine(" if (!csky_clutter_dither_keep(FRAGCOORD.xy, v_clutter_alpha)) { discard; }"); if (raceGhost) diff --git a/CSVM/src/Mech3/ShaderTwins.cs b/CSVM/src/Mech3/ShaderTwins.cs index 6773896b4..e15682fd5 100644 --- a/CSVM/src/Mech3/ShaderTwins.cs +++ b/CSVM/src/Mech3/ShaderTwins.cs @@ -31,9 +31,18 @@ internal static class ShaderTwins private static readonly Dictionary Tracked = new(ReferenceEqualityComparer.Instance); + // Each shader RetextInPlace rewrote, worn by a material of its own until two frames have drawn. + // ⚠ Keep a rewritten shader pinned until then. Godot recompiles new text only on a wearer's + // material update, and the first TAA frame's advanced-variant build (ShaderRD::enable_group) + // clears that pending recompile. An unworn shader would keep its old code for good. + private static readonly List Pins = new(); + // The mode the tracked materials last followed, null before the first. private static bool? _textEnhanced; + // Engine.GetFramesDrawn when the last pin went on. + private static int _pinnedAt; + /// Gets or sets a value indicating whether an Enhanced frame has drawn in this process. /// That is when Godot builds the advanced shader variants its passes need. public static bool EnhancedDrawn { get; set; } @@ -100,10 +109,12 @@ public static ShaderMaterial Copy(ShaderMaterial source) /// Moves every tracked material onto its key's shader for the current /// , making a shader not made yet. No shader's text changes, /// so nothing Godot compiled is thrown away. The exception is a first switch to Enhanced before - /// an Enhanced frame has drawn, which rewrites in place (). + /// an Enhanced frame has drawn, which rewrites in place (). It pins + /// each rewritten shader until Godot has recompiled it. public static RegenerateStats Regenerate() { _textEnhanced = GraphicsMode.Enhanced; + ReleasePins(); long start = System.Diagnostics.Stopwatch.GetTimestamp(); int made = Made; int retexted = 0; @@ -165,11 +176,15 @@ public static void ReleaseUnused() var unused = Tracked.Keys.Where(m => !GodotObject.IsInstanceValid(m) || m.GetReferenceCount() <= 1).ToList(); foreach (var material in unused) Tracked.Remove(material); + ReleasePins(); } /// Whether some key holds . For a suite. public static bool IsKeyShader(Shader? shader) => shader != null && All.Any(t => t.Slots().Contains(shader)); + /// Whether a rewritten is still worn by its pin. For a suite. + public static bool IsPinned(Shader shader) => Pins.Any(p => ReferenceEquals(p.Shader, shader)); + /// Whether follows a key. For a suite. public static bool IsTracked(ShaderMaterial material) => Tracked.ContainsKey(material); @@ -258,6 +273,7 @@ private static int RetextInPlace(bool enhanced) ByText.Remove(from.Code); from.Code = text; ByText[text] = from; + Pin(from); TextRewrites++; rewrote++; rewritten = true; @@ -278,6 +294,27 @@ private static int RetextInPlace(bool enhanced) return rewrote; } + // Puts a rewritten shader on a material of its own. Asking for the RID makes the server material, + // whose queued update recompiles the shader before the next frame draws a viewport. + private static void Pin(Shader shader) + { + var pin = new ShaderMaterial { Shader = shader }; + pin.GetRid(); + Pins.Add(pin); + _pinnedAt = Engine.GetFramesDrawn(); + } + + // Drops the pins once two frames have drawn since the last went on. The first draw carries the + // update that recompiles them, and the second is margin, so no free reaches the render thread first. + private static void ReleasePins() + { + if (Pins.Count == 0 || Engine.GetFramesDrawn() < _pinnedAt + 2) + return; + foreach (var pin in Pins) + pin.Dispose(); + Pins.Clear(); + } + /// What one did. It names the keys, the tracked materials and /// how many moved, the shaders made, the shaders rewritten and the wall time. internal readonly record struct RegenerateStats(int Entries, int Tracked, int Moved, int TwinsMade, int Retexted, double Ms); diff --git a/CSVM/src/Mech3/Zrdr.cs b/CSVM/src/Mech3/Zrdr.cs index b3bcc4f16..a0dc4fabe 100644 --- a/CSVM/src/Mech3/Zrdr.cs +++ b/CSVM/src/Mech3/Zrdr.cs @@ -129,6 +129,31 @@ public static class Zrdr } } + /// Whether a zrdr ZIP or directory carries under either + /// name accepts. It lets a caller tell an absent entry from a broken one + /// before reading; false when the archive itself is absent. + public static bool HasFile(string zrdrPath, string fileName) + { + if (Directory.Exists(zrdrPath)) + { + foreach (var candidate in CandidateNames(fileName)) + { + if (File.Exists(Path.Combine(zrdrPath, candidate))) + return true; + } + return false; + } + if (!File.Exists(zrdrPath)) + return false; + using var zip = ZipFile.OpenRead(zrdrPath); + foreach (var candidate in CandidateNames(fileName)) + { + if (zip.GetEntry(candidate) != null) + return true; + } + return false; + } + // The names a requested reader file may be stored under. mech3ax v0.6.1 // replaced the source extension ("vehicle.zrd" → "vehicle.json"); the fork appends // instead ("vehicle.zrd.json"), keeping the original extension visible. Content is diff --git a/CSVM/src/Session/Objectives/ObjectiveSites.cs b/CSVM/src/Session/Objectives/ObjectiveSites.cs index c76ccc0f3..9f9ced1e4 100644 --- a/CSVM/src/Session/Objectives/ObjectiveSites.cs +++ b/CSVM/src/Session/Objectives/ObjectiveSites.cs @@ -50,6 +50,7 @@ public sealed class ObjectiveSites private readonly Messages _messages; private readonly MissionTargets _targets; private readonly AnimRuntime? _runtime; + private readonly Func? _find; private readonly Dictionary _sites = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _nodes = new(StringComparer.OrdinalIgnoreCase); @@ -81,6 +82,15 @@ public ObjectiveSites(Messages messages, MissionTargets targets, AnimRuntime? ru _runtime = runtime; } + /// The same director-free feed on a stage with no world index, whose own + /// answers a key with the node it stands on, or null. + public ObjectiveSites(Messages messages, MissionTargets targets, Func find) + { + _messages = messages; + _targets = targets; + _find = find; + } + /// A team mode's labelling of a target key by side, null for a key the mode leaves to /// its record. Capture the Flag's bases and flags and Zeppelin vs Zeppelin's hulls read this /// way; every other session leaves it unset. @@ -401,12 +411,7 @@ private string Text(string? key) => return cached; } - if (_runtime == null) - { - return null; - } - - var found = ResolveTarget(_runtime, ObjectiveTarget.Parse(key)); + var found = _runtime != null ? ResolveTarget(_runtime, ObjectiveTarget.Parse(key)) : _find?.Invoke(key); if (found == null || !found.IsInsideTree()) { return null; diff --git a/CSVM/src/Session/Roster/SpawnPicker.cs b/CSVM/src/Session/Roster/SpawnPicker.cs index 9bd2b0056..b39097205 100644 --- a/CSVM/src/Session/Roster/SpawnPicker.cs +++ b/CSVM/src/Session/Roster/SpawnPicker.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Linq; using CSVM.Flight.Modes; +using CSVM.Mech3; using CSVM.Spec; using CSVM.Utils; using Godot; @@ -43,9 +45,10 @@ public SpawnPicker(SessionSpec spec) } /// Whether answered with a multiplayer mission's - /// net.zrd table rather than with an ia.json scenario list. It changes the - /// logged tag and , which then opens on the original's own - /// multiplayer throttle and speed (docs/formats/net-spawns.md). + /// net.zrd table, or the empty stage's own table in its place, rather than with an + /// ia.json scenario list. It changes the logged tag and , which + /// then opens on the original's own multiplayer throttle and speed + /// (docs/formats/net-spawns.md). public bool NetSpawns { get; private set; } /// Each seat's lobby team, 0 for none, set before by a @@ -61,22 +64,34 @@ public SpawnPicker(SessionSpec spec) /// public (int Start, int Count)[]? SeatBlocks { get; private set; } - private bool Teamed => SeatTeams != null && System.Linq.Enumerable.Any(SeatTeams, t => t > 0); + private bool Teamed => SeatTeams != null && SeatTeams.Any(t => t > 0); + + // What a log line names the launch and its multiplayer table by. The empty stage has neither a + // chapter nor a net.zrd, so naming them there would point a reader at data the run never read. + private string Where => _spec.EmptyStage ? "stage=empty" : $"{_spec.Chapter}/{_spec.Mission}"; + + private string NetTable => _spec.EmptyStage ? "spawn ring" : "net.zrd"; /// The one spawn list the session walks: the mission's ia.json entries for /// , else a Dogfight launch's net.zrd free-for-all block. With - /// neither it is null, which leaves on PLAYER_INIT. A team match takes the whole - /// table. Sets for the rest of the session, so call it once per launch. - /// + /// neither it is null, which leaves on PLAYER_INIT. A team match takes the + /// whole table. The empty stage walks , or for a team match + /// . Sets , so call it once per launch. public List? LoadSpawnList(string missionZrdrPath, string scenario) { SeatEntries = null; SeatBlocks = null; NetSpawns = false; - // The empty stage has no mission, so there is nothing to read: ChooseSpawn takes the - // --pos/default override placed over the grid origin. + // The empty stage has no mission, so there is nothing to read. A flight takes the --pos + // override placed over the grid origin; a Dogfight walks the stage's own table. if (_spec.EmptyStage) - return null; + { + if (!_spec.Versus) + return null; + NetSpawns = true; + var stage = Teamed ? EmptyStage.SpawnTable : EmptyStage.SpawnRing; + return stage.Select(s => new SpawnPoint(s.Position, s.HeadingDeg)).ToList(); + } var ia = SpawnPoints.LoadIa(missionZrdrPath, scenario); if (ia is { Count: > 0 }) return ia; @@ -100,7 +115,7 @@ public void PlanTeams(IReadOnlyList? spawns, int spawnBase) if (!NetSpawns || !Teamed || spawns is not { Count: > 0 }) return; (SeatEntries, SeatBlocks) = SpawnPoints.TeamBlocks(spawns.Count, SeatTeams!, spawnBase); - Log.Info("flight", $"net.zrd: team match over {spawns.Count} entries, openings {string.Join(",", SeatEntries)}"); + Log.Info("flight", $"{NetTable}: team match over {spawns.Count} entries, openings {string.Join(",", SeatEntries)}"); } /// The spawn index player 1 starts from: --spawn=N if given, else a random pick per @@ -148,7 +163,7 @@ public IReadOnlyList ChooseStarts(IReadOnlyList? spawns // own constants to the same placement call every other mode reaches through PLAYER_INIT. if (NetSpawns) { - Log.Info("flight", $"start [{_spec.Chapter}/{_spec.Mission} net.zrd] throttle={SpawnPoints.MultiplayerThrottleFrac:0.00} speed={SpawnPoints.MultiplayerSpeedMps:0.#}m/s ({SpawnPoints.MultiplayerSpeedMps * 2.2369363f:0}mph)"); + Log.Info("flight", $"start [{Where} {NetTable}] throttle={SpawnPoints.MultiplayerThrottleFrac:0.00} speed={SpawnPoints.MultiplayerSpeedMps:0.#}m/s ({SpawnPoints.MultiplayerSpeedMps * 2.2369363f:0}mph)"); return (SpawnPoints.MultiplayerThrottleFrac, SpawnPoints.MultiplayerSpeedMps); } @@ -201,7 +216,7 @@ public IReadOnlyList ChooseStarts(IReadOnlyList? spawns int i = SeatEntries is { } entries && playerIndex < entries.Length ? entries[playerIndex] : (spawnBase + playerIndex) % spawns.Count; - string list = NetSpawns ? "net.zrd" : ScenarioOverride ?? _spec.Scenario; + string list = NetSpawns ? NetTable : ScenarioOverride ?? _spec.Scenario; return LogSpawn($"{tag}{list} #{i} of {spawns.Count}", spawns[i]); } // No instant-action spawns (only IA1 folders have ia.json), use the story-mission @@ -244,7 +259,7 @@ public IReadOnlyList ChooseStarts(IReadOnlyList? spawns // (-Z) rotated by the heading (yaw about up), and logs it for cross-checking the data. private (Vector3 pos, Vector3 lookAt) LogSpawn(string label, SpawnPoint s) { - Log.Info("flight", $"spawn [{_spec.Chapter}/{_spec.Mission} {label}] pos=({s.Position.X:0},{s.Position.Y:0},{s.Position.Z:0}) heading={s.HeadingDeg:0}°"); + Log.Info("flight", $"spawn [{Where} {label}] pos=({s.Position.X:0},{s.Position.Y:0},{s.Position.Z:0}) heading={s.HeadingDeg:0}°"); return (s.Position, s.Position + s.Forward); } } diff --git a/CSVM/src/Session/World/RearmRuntime.cs b/CSVM/src/Session/World/RearmRuntime.cs index 5de520449..3169cf877 100644 --- a/CSVM/src/Session/World/RearmRuntime.cs +++ b/CSVM/src/Session/World/RearmRuntime.cs @@ -25,7 +25,12 @@ internal sealed class RearmRuntimeInputs /// Each seat's lobby team, or null for a match with no teams. public IReadOnlyList? SeatTeams { get; init; } - public required AnimRuntime World { get; init; } + /// The mission world, whose nodes are looked up first. Null on the empty stage. + public AnimRuntime? World { get; init; } + + /// The empty stage's match arena (), looked up by node + /// name after . Null on a chapter. + public Node3D? Arena { get; init; } public bool CaptureTheFlag { get; init; } @@ -79,7 +84,7 @@ private RearmRuntime(RearmRuntimeInputs inputs, RearmBases rules, List<(Node3D, ArgumentNullException.ThrowIfNull(inputs); bool zeppelins = inputs.Zeppelins != null; var nodes = new List<(Node3D, int, int)>(); - for (int index = 1; Find(inputs.World, RearmBases.NodeName(index, zeppelins)) is { } node; index++) + for (int index = 1; Find(inputs, RearmBases.NodeName(index, zeppelins)) is { } node; index++) { int team = zeppelins ? inputs.Zeppelins!.Rules.TeamOfHull(index - 1) : index; nodes.Add((node, team, zeppelins ? inputs.Zeppelins!.Rules.HullOf(team) : -1)); @@ -145,9 +150,9 @@ public void Step() } } - private static Node3D? Find(AnimRuntime world, string name) + private static Node3D? Find(RearmRuntimeInputs inputs, string name) { - foreach (var node in world.FindNodes(name)) + foreach (var node in inputs.World?.FindNodes(name) ?? Array.Empty()) { if (GodotObject.IsInstanceValid(node) && node.IsInsideTree()) { @@ -155,6 +160,15 @@ public void Step() } } + foreach (var child in inputs.Arena?.GetChildren() ?? new Godot.Collections.Array()) + { + if (child is Node3D node && node.IsInsideTree() && node.HasMeta(AnimRuntime.NameMeta) + && node.GetMeta(AnimRuntime.NameMeta).AsString().Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return node; + } + } + return null; } diff --git a/CSVM/src/Session/World/VersusDirector.cs b/CSVM/src/Session/World/VersusDirector.cs index 3c5f4bd09..cb78a1244 100644 --- a/CSVM/src/Session/World/VersusDirector.cs +++ b/CSVM/src/Session/World/VersusDirector.cs @@ -357,10 +357,10 @@ internal void WireZeppelinVersus(ZeppelinVersusWireInputs inputs) /// The rearm bases of any Dogfight, over the wire or split screen. Zeppelin vs Zeppelin /// rearms only at its hulls' own nodes, so a match that could not seat both hulls has no base - /// at all. - internal void WireRearmBases(AnimRuntime? world, string zrdrPath, ZeppelinRuntime? hulls) + /// at all. On the empty stage the bases stand in its match . + internal void WireRearmBases(AnimRuntime? world, Node3D? arena, string zrdrPath, ZeppelinRuntime? hulls) { - if (world == null || (_spec.MissionType == DogfightMissionType.ZeppelinVsZeppelin && ZvzPlay == null)) + if ((world == null && arena == null) || (_spec.MissionType == DogfightMissionType.ZeppelinVsZeppelin && ZvzPlay == null)) { return; } @@ -371,6 +371,7 @@ internal void WireRearmBases(AnimRuntime? world, string zrdrPath, ZeppelinRuntim IsLocal = seat => _field.NetSeats.Count == 0 || IsLocal(seat), SeatTeams = SeatTeams(_spec, _field.NetSeats), World = world, + Arena = arena, CaptureTheFlag = _spec.MissionType == DogfightMissionType.CaptureTheFlag, Zeppelins = ZvzPlay, Hulls = hulls, diff --git a/CSVM/src/Spec/SessionSpec.cs b/CSVM/src/Spec/SessionSpec.cs index 94128bac8..e57d129eb 100644 --- a/CSVM/src/Spec/SessionSpec.cs +++ b/CSVM/src/Spec/SessionSpec.cs @@ -71,9 +71,9 @@ public enum DogfightMissionType : byte StuntRace = 3, } -/// The enhanced presentation's screen-space passes, as doors a run can close one at a -/// time. Each can lay a pattern of its own over the frame. Bisecting a full-screen artefact means -/// rendering one pose per closed door until the pattern goes. Reaches +/// The enhanced presentation's screen-space passes and world layers, as doors a run can +/// close one at a time. Each can lay a pattern of its own over the frame. Bisecting a full-screen +/// artefact means rendering one pose per closed door until the pattern goes. Reaches /// --graphics=enhanced alone, the faithful path running none of them. [Flags] public enum EnhancedPasses @@ -92,6 +92,10 @@ public enum EnhancedPasses /// --no-soft-shadows: the sun's penumbra, angular distance and blur both 0, /// leaving a hard shadow edge rather than no shadow. SoftShadows = 8, + + /// --no-ocean: the wave ocean (Effects.Ocean), so the flat sea-level + /// sheet draws as it does without one. + Ocean = 16, } /// One --ai= entry: the airframe, plus the optional tokens that follow it. @@ -460,6 +464,10 @@ private SessionSpec() /// See docs/cli.md. public EnhancedPasses SkippedPasses { get; private set; } + /// --dump-ocean-mask=<path>: where the wave ocean writes its baked shore + /// mask as a PNG when it builds; empty for none. See docs/cli.md. + public string OceanMaskPath { get; private set; } = ""; + /// --debug-clutterflag: recolour the built world by each polygon's decoded /// no_clutter flag (raw polygon bit 0x800, ) ///, flagged red, clear green, clutter blue. A build-time recolour, so there is no runtime @@ -509,7 +517,12 @@ private SessionSpec() // ---- The aircraft ------------------------------------------------------------------------- - public string PlaneName { get; private set; } = "player_bhawk"; + /// The aircraft a spec flies when --plane= names none, set once at startup. + /// --synthetic-data points it at the stand-in, the one aircraft its tree carries. A spec + /// a suite builds without naming a plane then flies what the data holds. + public static string DefaultPlane { get; set; } = "player_bhawk"; + + public string PlaneName { get; private set; } = DefaultPlane; /// The --plane= list; empty when a single plane (or none) was named. public IReadOnlyList PlaneNames { get; private set; } = Array.Empty(); /// Resolved. Splitscreen panes. A --plane= list of several states the @@ -949,6 +962,10 @@ private SessionSpec() /// Resolved. Same treatment as , through /// . public string? DebugDamage { get; private set; } + /// Resolved. --debug-ocean=<field>:<value>,...[,open]: the ocean + /// lab's overrides for this launch, filtered through + /// and null outside --freecam, the only mode the lab exists in. + public string? DebugOcean { get; private set; } public int DebugJoin { get; private set; } /// --debug-waves=N (launchscreen only): pre-configure the first N /// (clamped 0-4) Instant Action wizard wave slots with a representative load, so the wave @@ -1037,6 +1054,12 @@ private SessionSpec() /// repo root, and the paths derived from the winner, are resolution. public string? DataRoot { get; private set; } + /// --synthetic-data: write an invented extraction tree into the scratch folder + /// and read it in place of the data root. Off unless given, and dropped with a note beside + /// --extract, which would otherwise write an extraction into that tree. See + /// . + public bool SyntheticData { get; private set; } + /// --extract=<install> verbatim: extract that install into the data root's /// extracted folder and quit with the verdict. Empty for a bare --extract, which /// the install check refuses; null when the flag was absent. @@ -1082,6 +1105,11 @@ private SessionSpec() /// the saved option and the config key, including under --det. public string? ViewDistance { get; private set; } + /// --water-quality=flat|waves, whether Enhanced draws the wave ocean or the flat + /// sea. Null when not given, and for an unknown word with a warning. Beats the saved option and the + /// config key, including under --det. --no-ocean still wins over waves. + public string? WaterQuality { get; private set; } + /// --debug-graphics-switch=N[,N...]: the sim frames at which the running session /// flips the graphics mode, as the Toggle Graphics Mode action does but unsaved. The scripted /// twin of the live switch, so a capture shows a world after a round trip. Empty = none. @@ -1402,6 +1430,8 @@ void Deprecate(string old, string replacement) } else if (arg == "--debug-damage") { s.DebugDamage ??= ""; } else if (arg.StartsWith("--debug-damage=")) { s.DebugDamage = arg["--debug-damage=".Length..]; } + else if (arg == "--debug-ocean") { s.DebugOcean ??= ""; } + else if (arg.StartsWith("--debug-ocean=")) { s.DebugOcean = arg["--debug-ocean=".Length..]; } else if (arg == "--markers") { s.MarkersOverlay = true; s.HasContentArg = true; } else if (arg == "--dump-markers") { s.DumpMarkers = true; } else if (arg.StartsWith("--dump-markers=")) { s.DumpMarkers = true; s.DumpMarkersPlane = arg["--dump-markers=".Length..]; } @@ -1640,6 +1670,7 @@ void Deprecate(string old, string replacement) else if (arg.StartsWith("--spawn-dir=")) { s.SpawnDir = ParseVec3(arg["--spawn-dir=".Length..]); Deprecate("--spawn-dir", "--direction"); } else if (arg.StartsWith("--sky-zone=")) { s.SkyZone = arg["--sky-zone=".Length..]; s.SkyZoneExplicit = true; } else if (arg.StartsWith("--data-root=")) { s.DataRoot = arg["--data-root=".Length..]; } + else if (arg == "--synthetic-data") { s.SyntheticData = true; } else if (arg == "--extract") { s.ExtractInstall = ""; } else if (arg.StartsWith("--extract=")) { s.ExtractInstall = arg["--extract=".Length..]; } else if (arg == "--extract-force") { s.ExtractForce = true; } @@ -1660,6 +1691,8 @@ void Deprecate(string old, string replacement) else if (arg == "--no-ssr") { s.SkippedPasses |= EnhancedPasses.Ssr; } else if (arg == "--no-glow") { s.SkippedPasses |= EnhancedPasses.Glow; } else if (arg == "--no-soft-shadows") { s.SkippedPasses |= EnhancedPasses.SoftShadows; } + else if (arg == "--no-ocean") { s.SkippedPasses |= EnhancedPasses.Ocean; } + else if (arg.StartsWith("--dump-ocean-mask=")) { s.OceanMaskPath = arg["--dump-ocean-mask=".Length..]; } else if (arg.StartsWith("--mips=")) { s.SetMips(arg["--mips=".Length..]); } else if (arg.StartsWith("--graphics=")) { @@ -1681,6 +1714,10 @@ void Deprecate(string old, string replacement) { s.ViewDistance = viewWord ?? s.ViewDistance; } + else if (TryWordFlag(arg, Utils.WaterQualitySetting.Lookup, notes, out string? waterWord)) + { + s.WaterQuality = waterWord ?? s.WaterQuality; + } else if (arg == "--dump-mips") { s.DumpMips = true; } else if (arg.StartsWith("--dump-mips=")) { s.DumpMips = true; s.DumpMipsFilter = arg["--dump-mips=".Length..]; } else if (arg == "--dump-ai") { s.DumpAi = true; } @@ -1819,6 +1856,16 @@ void Deprecate(string old, string replacement) } } } + else if (s.SyntheticData) + { + notes.Add(new Note("core", $"--synthetic-data with --extract would extract into the invented tree, ignoring --synthetic-data")); + s.SyntheticData = false; + } + + if (s.SyntheticData && s.PlaneNames.Count == 0) + { + s.PlaneName = Tooling.SyntheticPlane.Plane; + } if (netHost != null) { @@ -2522,6 +2569,11 @@ private void Resolve() Warn("ui", "--debug-damage is a --freecam/--anim-lab tool; ignoring it here (--damage-test is the headless twin)"); DebugDamage = null; } + if (DebugOcean != null && Mode != SessionMode.Freecam) + { + Warn("ui", "--debug-ocean is the --freecam ocean lab's twin; ignoring it here"); + DebugOcean = null; + } // A preset with nothing to apply to would otherwise be a silent no-op, and a run that shows // no damage would read as the staging being broken rather than as the flag being unused. if (AiHullDamage != null && AiPlanes == null) @@ -2542,6 +2594,8 @@ private void Resolve() "is not deps/dest/open/all/node="); DebugDamage = FilterSpec(DebugDamage, ParseDamageScript, "--debug-damage step", "is not node=/pool=/hp=/kill/reset/tick=/open"); + DebugOcean = FilterSpec(DebugOcean, Effects.SeaState.FilterOverrides, "--debug-ocean token", + "is not open or : over a SeaState field"); // --stage= replaces the chapter world outright, so it is a flight/spectator affair: there // is no gamez to inspect, which is what the static viewer and the anim lab exist for. @@ -2697,13 +2751,17 @@ private void ResolvePlacement() { Warn("core", "--direction ignored: flight steers the nose from the spawn override, which needs --pos"); } - // The empty stage has no mission spawn list to draw from, so the subject starts over the - // grid origin, through the same fields --pos resolves into, so an explicit placement wins. + // The empty stage has no mission spawn list, so the subject starts over the grid origin. It + // goes through the fields --pos resolves into, so an explicit placement wins. A Dogfight + // walks the stage's own spawn ring instead, which a default here would beat. if (EmptyStage) { if (Fly) { - SpawnAt ??= new Vector3(0f, Mech3.EmptyStage.SpawnAltitude, 0f); + if (!Versus) + { + SpawnAt ??= new Vector3(0f, Mech3.EmptyStage.SpawnAltitude, 0f); + } } else { diff --git a/CSVM/src/Testing/AiSuites.cs b/CSVM/src/Testing/AiSuites.cs index cc28bdcd4..e0822e52e 100644 --- a/CSVM/src/Testing/AiSuites.cs +++ b/CSVM/src/Testing/AiSuites.cs @@ -399,14 +399,20 @@ internal static void CarriedTurrets(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); var turretDefs = TurretDefs.Load(ctx.ZrdrPath); - ctx.Check(turretDefs.All.Count(d => d.Carried) == 16, - $"the 16 carried ai.zrd entries parse carried={turretDefs.All.Count(d => d.Carried)}"); + ctx.Check(turretDefs.All.Any(d => d.Carried), + $"a carried ai.zrd entry parses carried={turretDefs.All.Count(d => d.Carried)}"); + if (!ctx.SyntheticData) + { + ctx.Check(turretDefs.All.Count(d => d.Carried) == 16, + $"the 16 carried ai.zrd entries parse carried={turretDefs.All.Count(d => d.Carried)}"); + } // The Kestrel: a single rear turret (thirdp MSG_TUR_PAC_G3, PITCH [20,50], YAW // [105,255], the directed rear arc through 180°, DETECTION_RANGE 450, FIRE_RATE 0.4, @@ -469,9 +475,15 @@ FlightController BuildRig(string plane, PlaneStats st, int playerIndex, Vector3 // Load pose: the centre of each arc, yaw 180 (rearward), pitch 35. var (restYaw, restPitch) = TurretController.AnglesOfLocal(turret.BarrelLocal); - ctx.Check(Mathf.Abs(Mathf.Wrap(restYaw - 180f, -180f, 180f)) < 0.5f - && Mathf.Abs(restPitch - 35f) < 0.5f, - $"the turret poses at its arc centre yaw={restYaw:0.#} pitch={restPitch:0.#}"); + ctx.Check(Mathf.Abs(Mathf.Wrap(restYaw - turret.Def.RestYawDeg, -180f, 180f)) < 0.5f + && Mathf.Abs(restPitch - turret.Def.RestPitchDeg) < 0.5f, + $"the turret poses at its arc centre yaw={restYaw:0.#} pitch={restPitch:0.#} (the arcs' {turret.Def.RestYawDeg:0.#}, {turret.Def.RestPitchDeg:0.#})"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.Abs(Mathf.Wrap(restYaw - 180f, -180f, 180f)) < 0.5f + && Mathf.Abs(restPitch - 35f) < 0.5f, + $"the Kestrel's arc centre is yaw 180, pitch 35: yaw={restYaw:0.#} pitch={restPitch:0.#}"); + } // The target: in-arc (behind and above the host, yaw ~180, elevation ~35°), inside // DETECTION_RANGE, on a hostile team. INACCURACY is zeroed so every gated round flies @@ -548,8 +560,16 @@ void Step(int frames) var (parkedYaw, _) = TurretController.AnglesOfLocal(turret.BarrelLocal); ctx.Check(turret.ShotsFired == shotsAtOutOfArc, $"an out-of-arc target draws no fire shots={turret.ShotsFired}"); - ctx.Check(Mathf.Abs(parkedYaw - 105f) < 1.5f, - $"the barrel parks at the nearer end stop (105°, not 255°) yaw={parkedYaw:0.#}"); + float lowStop = turret.Def.YawMinDeg ?? 0f, highStop = turret.Def.YawMaxDeg ?? 0f; + float StopGap(float stop) => Mathf.Abs(Mathf.Wrap(20f - stop, -180f, 180f)); + float nearStop = StopGap(lowStop) <= StopGap(highStop) ? lowStop : highStop; + ctx.Check(Mathf.Abs(Mathf.Wrap(parkedYaw - nearStop, -180f, 180f)) < 1.5f, + $"the barrel parks at the end stop nearer the target ({nearStop:0}° of {lowStop:0}°/{highStop:0}°) yaw={parkedYaw:0.#}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.Abs(parkedYaw - 105f) < 1.5f, + $"the barrel parks at the nearer end stop (105°, not 255°) yaw={parkedYaw:0.#}"); + } // --- YAW [0,0] means UNRESTRICTED: with the limit spelled that way the same ahead // target becomes reachable and the turret opens fire, the misread ('locked forward') @@ -601,6 +621,7 @@ internal static void WorldTurrets(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); @@ -1093,6 +1114,7 @@ internal static void TurretSelfFire(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); @@ -1856,18 +1878,34 @@ void Step(int frames) Step(1); ctx.Check(ReferenceEquals(gunner.Target, target), $"a cleared target re-acquires next tick"); - // --- the quick-draw gate: 80° off the target's tail axis (a beam-ish shot). At - // rating 1 the ~54° cone refuses it; at rating 9 the 89° cone takes it. - var beamPos = targetPos + new Vector3(0.9848f, 0f, 0.1736f) * 500f; // 80° off +Z - ai.PlaceHeld(beamPos, targetPos); - int ammoAtBeam = gun.Ammo; - Step(60); - ctx.Check(!gunner.WantsFire && gun.Ammo == ammoAtBeam, - $"a beam-ish shot is refused at quick-draw rating 1 (~54°) rounds={ammoAtBeam - gun.Ammo}"); - gunner.QuickDrawAngleDeg = skills.QuickDrawAngleDeg(9); - Step(60); - ctx.Check(gun.Ammo < ammoAtBeam, - $"the same bearing is taken at rating 9 (89°) rounds={ammoAtBeam - gun.Ammo}"); + // --- the quick-draw gate: a beam-ish shot off the target's tail axis, refused inside + // rating 1's cone and taken inside rating 9's. The bearing sits between the two cones the + // record gives; on the install the shipped 80° bearing between ~54° and 89° runs too. + float quickDraw1 = skills.QuickDrawAngleDeg(1), quickDraw9 = skills.QuickDrawAngleDeg(9); + Vector3 BeamAt(float deg) => targetPos + + new Vector3(Mathf.Sin(Mathf.DegToRad(deg)), 0f, Mathf.Cos(Mathf.DegToRad(deg))) * 500f; + void QuickDraw(Vector3 at, string refused, string taken) + { + gunner.QuickDrawAngleDeg = quickDraw1; + ai.PlaceHeld(at, targetPos); + int ammoAtBeam = gun.Ammo; + Step(60); + ctx.Check(!gunner.WantsFire && gun.Ammo == ammoAtBeam, $"{refused} rounds={ammoAtBeam - gun.Ammo}"); + gunner.QuickDrawAngleDeg = quickDraw9; + Step(60); + ctx.Check(gun.Ammo < ammoAtBeam, $"{taken} rounds={ammoAtBeam - gun.Ammo}"); + } + + float beamDeg = (quickDraw1 + quickDraw9) * 0.5f; + var beamPos = BeamAt(beamDeg); + QuickDraw(beamPos, $"a shot {beamDeg:0.#}° off the tail is refused at quick-draw rating 1 ({quickDraw1:0.#}°)", + $"the same bearing is taken at rating 9 ({quickDraw9:0.#}°)"); + if (!ctx.SyntheticData) + { + beamPos = targetPos + new Vector3(0.9848f, 0f, 0.1736f) * 500f; // 80° off +Z + QuickDraw(beamPos, $"a beam-ish shot is refused at quick-draw rating 1 (~54°)", + $"the same bearing is taken at rating 9 (89°)"); + } // --- the aim gate: nose 30° off the bearing clamps to the airframe's 11° and leaves // a 19° residual, past the gun's 10°, so the shot is refused with quick draw willing. @@ -2101,9 +2139,17 @@ internal static void AiModes(TestContext ctx) if (gun == null) return; - // The shipped range gates the machine runs on. - ctx.Check(Mathf.IsEqualApprox(skills.MinAiActiveDist, 2000f), - $"player.json min_ai_active_dist is the decoded 2000 m got={skills.MinAiActiveDist:0}"); + // The range gates the machine runs on, read back against the record; the install pins the + // shipped radius too. + float activeDist = skills.MinAiActiveDist; + ctx.Check(SuiteConstants.PlayerGlobal(ctx.ZrdrPath, "min_ai_active_dist") is { } authored + && Mathf.IsEqualApprox(activeDist, authored), + $"player.json min_ai_active_dist reaches the machine as authored got={activeDist:0}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(skills.MinAiActiveDist, 2000f), + $"player.json min_ai_active_dist is the decoded 2000 m got={skills.MinAiActiveDist:0}"); + } ctx.Check(Mathf.IsEqualApprox(stats.AiAttackRange, 2000f) && Mathf.IsEqualApprox(stats.AiReturnRange, 1200f), $"vehicle.json attack/return_range are the decoded 2000/1200 m got={stats.AiAttackRange:0}/{stats.AiReturnRange:0}"); @@ -2165,7 +2211,8 @@ internal static void AiModes(TestContext ctx) string? lastRoll = null; machine.RollLogged += line => lastRoll = line; - var aiPos = targetPos + new Vector3(0f, 0f, 2600f); // outside the 2000 m radius + // Outside the radius the promotion admits a quarry from, the airframe's attack volume. + var aiPos = targetPos + new Vector3(0f, 0f, stats.AiAttackRange + 600f); var pilot = AiPilot.HoldingCourse(aiPos, targetPos); pilot.Gunner = new AiGunner(new RandomNumberGenerator { Seed = 20260813 }) { @@ -2207,14 +2254,14 @@ void Step(int frames) // pursue, announced in the decoded vocabulary. ctx.Check(machine.Mode == AiMode.Patrol, $"the machine starts on patrol"); Step(1); - ctx.Check(machine.Mode == AiMode.Patrol && Dist() > 2000f, - $"outside min_ai_active_dist it stays on patrol d={Dist():0} m"); + ctx.Check(machine.Mode == AiMode.Patrol && Dist() > stats.AiAttackRange, + $"outside the attack radius it stays on patrol d={Dist():0} m"); int budget = 60 * 60; while (machine.Mode == AiMode.Patrol && budget-- > 0) Step(1); ctx.Check(machine.Mode == AiMode.Pursue, $"the approach activates it into pursue d={Dist():0} m"); - ctx.Check(Dist() <= 2010f, $"…at the activation radius, not before d={Dist():0} m"); + ctx.Check(Dist() <= stats.AiAttackRange + 10f, $"…at the activation radius, not before d={Dist():0} m"); ctx.Check(transitions.Contains("patrol>pursue"), $"…logged as patrol>pursue transitions=[{string.Join(" ", transitions)}]"); @@ -2352,8 +2399,18 @@ void Step(int frames) ctx.Check(machine.ClimbOutAltitude > yBefore, $"…ordering a climb-out to {machine.ClimbOutAltitude:0} m from {yBefore:0} m"); Step(240); + // How fast it climbs back past the order's altitude is the airframe's: the install's + // plane is past it inside 4 s. Any airframe must be back past it while the override + // holds, inside 8 s of the order. + if (!ctx.SyntheticData) + { + ctx.Check(ai.WorldPosition.Y > yBefore, + $"the plane is climbing out y={ai.WorldPosition.Y:0} from {yBefore:0}"); + } + for (int i = 0; i < 240 && ai.WorldPosition.Y <= yBefore && machine.Mode == AiMode.AvoidCrash; i++) + Step(1); ctx.Check(ai.WorldPosition.Y > yBefore, - $"the plane is climbing out y={ai.WorldPosition.Y:0} from {yBefore:0}"); + $"the plane climbs back out past the order's altitude y={ai.WorldPosition.Y:0} from {yBefore:0}"); terrainBlocked = false; Step(120); ctx.Check(machine.Mode != AiMode.AvoidCrash, @@ -2458,10 +2515,27 @@ internal static void VoiceRuntime(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"shared zrdr"); ctx.RequireData(ctx.SoundsPath, $"sound archive (soundsh)"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "voice.json"); var defs = SoundDefs.Load(ctx.ZrdrPath); var groups = SoundDefs.LoadGroups(ctx.ZrdrPath); var voice = new CombatVoice(defs, groups, CombatVoice.LoadAccents(ctx.ZrdrPath)); - ctx.Same(35, voice.AccentIds.Count, $"voice.zrd accent rows"); + if (ctx.SyntheticData) + ctx.Check(voice.AccentIds.Count > 0, $"voice.zrd has accent rows count={voice.AccentIds.Count}"); + else + ctx.Same(35, voice.AccentIds.Count, $"voice.zrd accent rows"); + + // The install's worked example is accent 12, VO id 2 (the pilot with the full bearing + // set), and id 26 a pilot outside its pool. The synthetic tree takes the first one-pilot + // accent whose pilot authors a DI-LowDmg group, and any other pilot. + int accent = ctx.SyntheticData + ? voice.AccentIds.FirstOrDefault(a => voice.Pool(a).Length == 1 + && voice.PilotFor(a, 0) is int p && voice.PlayableFor(p, "DI-LowDmg")?.EndsWith("_random") == true) + : 12; + int vo = ctx.SyntheticData ? voice.PilotFor(accent, 0) ?? -1 : 2; + int stranger = ctx.SyntheticData ? voice.PilotIds.FirstOrDefault(id => id != vo) : 26; + string unprewarmed = ctx.SyntheticData + ? voice.ClipsFor(stranger, "TA-SucShk").FirstOrDefault() ?? "none" + : "snd_id26_TA-SucShk-A"; using var archive = new SoundArchive(ctx.SoundsPath); WorldSounds? sounds = null; @@ -2474,22 +2548,23 @@ internal static void VoiceRuntime(TestContext ctx) }; ctx.Host.AddChild(sounds); - // The chain's worked example: accent 12 is a single-id pool, VO id 2 (the pilot with - // the full bearing set). Prewarm that pilot exactly as a mission roster would. - int? pilot = voice.PilotFor(12, 0); - ctx.Check(pilot == 2, $"accent 12 resolves to VO id 2 got={pilot?.ToString() ?? "null"}"); - var subset = voice.PrewarmNames(new[] { 12 }); + // The chain's worked example, a single-id pool. Prewarm that pilot exactly as a mission + // roster would. + int? pilot = voice.PilotFor(accent, 0); + ctx.Check(pilot == vo && vo >= 0, $"accent {accent} resolves to VO id {vo} got={pilot?.ToString() ?? "null"}"); + var subset = voice.PrewarmNames(new[] { accent }); var sw = System.Diagnostics.Stopwatch.StartNew(); int decoded = sounds.Prewarm(subset); sw.Stop(); - ctx.Check(decoded >= 70, $"the accent-12 subset decodes names={subset.Count} decoded={decoded}"); - ctx.Note($"accent-12 prewarm: {subset.Count} defs, {decoded} streams, {sw.ElapsedMilliseconds} ms"); + ctx.Check(ctx.SyntheticData ? decoded == subset.Count && decoded > 0 : decoded >= 70, + $"the accent-{accent} subset decodes names={subset.Count} decoded={decoded}"); + ctx.Note($"accent-{accent} prewarm: {subset.Count} defs, {decoded} streams, {sw.ElapsedMilliseconds} ms"); sounds.Loader = null; // the session's build scope closing (WorldSession.Build) - string? playable = voice.PlayableFor(2, "DI-LowDmg"); - ctx.Check(playable == "snd_DI-LowDmg-A_id2_random", - $"DI-LowDmg resolves to the shipped variant group got={playable}"); - string? bearing = voice.PlayableForTrigger(2, 6); // WA-Enemy-3H + string? playable = voice.PlayableFor(vo, "DI-LowDmg"); + ctx.Check(playable == $"snd_DI-LowDmg-A_id{vo}_random", + $"DI-LowDmg resolves to the authored variant group got={playable}"); + string? bearing = voice.PlayableForTrigger(vo, 6); // WA-Enemy-3H ctx.Check(bearing != null && sounds.HasStream(bearing), $"the bearing clip's stream survived the loader retirement name={bearing}"); @@ -2497,7 +2572,7 @@ internal static void VoiceRuntime(TestContext ctx) radio = new MissionRadio(defs, groups, sounds.StreamFor); ctx.Host.AddChild(radio); string? resolved = radio.Speak(playable!, new System.Random(2)); - ctx.Check(resolved != null && resolved.StartsWith("snd_id2_DI-LowDmg"), + ctx.Check(resolved != null && resolved.StartsWith($"snd_id{vo}_DI-LowDmg"), $"a prewarmed voice line queues after the archive closed resolved={resolved}"); radio.Tick(0.1f); ctx.Check(radio.LinesStarted == 1 && radio.OnAir == resolved, @@ -2505,8 +2580,8 @@ internal static void VoiceRuntime(TestContext ctx) // A def never prewarmed is null once the loader is gone: the exact failure the // prewarm exists to prevent. - ctx.Check(radio.Speak("snd_id26_TA-SucShk-A", new System.Random(3)) == null, - $"an unprewarmed pilot's line stays null after the archive closed"); + ctx.Check((!ctx.SyntheticData || defs.ContainsKey(unprewarmed)) && radio.Speak(unprewarmed, new System.Random(3)) == null, + $"an unprewarmed pilot's line stays null after the archive closed ({unprewarmed}, VO id {stranger})"); // The cost of prewarm-everything, measured on a fresh archive so nothing is cached: // the number the roster-subset strategy is justified against. @@ -2583,6 +2658,10 @@ internal static void AiVoice(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "voice.json"); + // The WA-Turret leg builds the carrier's gunner off the turret table. + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); + ctx.RequirePlane(TurretCarrier); ctx.RequireData(ctx.SoundsPath, $"sound archive (soundsh)"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); @@ -2592,7 +2671,12 @@ internal static void AiVoice(TestContext ctx) var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); var defs = SoundDefs.Load(ctx.ZrdrPath); var groups = SoundDefs.LoadGroups(ctx.ZrdrPath); - var voice = new CombatVoice(defs, groups, CombatVoice.LoadAccents(ctx.ZrdrPath)); + // The install's accent 12 speaks as pilot VO id 2; another tree stands in its first one-voice accent. + var accents = CombatVoice.LoadAccents(ctx.ZrdrPath); + var (accent, accentVo) = ctx.SyntheticData && accents.FirstOrDefault(a => a.Value.Length == 1) is { Value: { } pool } single + ? (single.Key, pool[0]) + : (12, 2); + var voice = new CombatVoice(defs, groups, accents); WeaponDef? gun = weapons.All.FirstOrDefault(w => w.IsGun && w.ArmorDamage is > 0f && w.HealthDamage is > 0f); ctx.Check(gun != null && stats.DestroyableParts.Count > 0, @@ -2619,7 +2703,7 @@ internal static void AiVoice(TestContext ctx) Loader = (d, warn) => archive.Find(d.WavName, d.Looped, warn), }; ctx.Host.AddChild(sounds); - sounds.Prewarm(voice.PrewarmNames(new[] { 12 })); + sounds.Prewarm(voice.PrewarmNames(new[] { accent })); sounds.Loader = null; radio = new MissionRadio(defs, groups, sounds.StreamFor); @@ -2647,10 +2731,10 @@ internal static void AiVoice(TestContext ctx) // Talker chance pinned to 1: every roll passes, so a silent trigger is a GATE // decision (cooldown, aliveness), never luck. - runtime.RegisterAi(ai, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + runtime.RegisterAi(ai, accentId: accent, talkerChance: 1f, constitutionChance: 1f); var speaker = runtime.Dispatcher.Find(ai.PlayerIndex); - ctx.Check(speaker is { VoId: 2 }, - $"accent 12 registers the AI as VO id 2 got={speaker?.VoId.ToString() ?? "none"}"); + ctx.Check(speaker?.VoId == accentVo, + $"accent {accent} registers the AI as VO id {accentVo} got={speaker?.VoId.ToString() ?? "none"}"); if (speaker == null) return; @@ -2805,8 +2889,8 @@ FlightController Rig(int index, int team, bool human) var killer = Rig(FlightRoster.ShooterIdBase + 1, enemyTeam, human: false); var wingman = Rig(FlightRoster.ShooterIdBase + 2, AimAssist.PlayerTeam, human: false); var pilotRig = Rig(0, AimAssist.PlayerTeam, human: true); - runtime.RegisterAi(killer, accentId: 12, talkerChance: 1f, constitutionChance: 1f); - runtime.RegisterAi(wingman, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + runtime.RegisterAi(killer, accentId: accent, talkerChance: 1f, constitutionChance: 1f); + runtime.RegisterAi(wingman, accentId: accent, talkerChance: 1f, constitutionChance: 1f); runtime.RegisterPlayer(pilotRig); // The first predicate, over shooter and victim: a kill inside one team picks no gloat. @@ -2859,7 +2943,7 @@ FlightController Rig(int index, int team, bool human) var evader = Rig(FlightRoster.ShooterIdBase + 7, enemyTeam, human: false); var machine = new AiModeMachine(new System.Random(11)); evader.Pilot!.Machine = machine; - runtime.RegisterAi(evader, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + runtime.RegisterAi(evader, accentId: accent, talkerChance: 1f, constitutionChance: 1f); var evaderPos = evader.WorldPosition; var pursuerPos = evaderPos + new Vector3(0f, 0f, 300f); var onTail = (evaderPos - pursuerPos).Normalized(); // the pursuer's nose on the evader @@ -3059,8 +3143,8 @@ void StepGun(int frames) // ⚠ The flight mate is registered at 2: the bearing ids halve the talker chance, so a // chance of 1 would make every assertion below a coin flip on the dice rather than on // the raise. The halving itself is pinned by AiVoiceDispatcherTests. - attack.RegisterAi(flightMate, accentId: 12, talkerChance: 2f, constitutionChance: 1f); - attack.RegisterAi(ace, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + attack.RegisterAi(flightMate, accentId: accent, talkerChance: 2f, constitutionChance: 1f); + attack.RegisterAi(ace, accentId: accent, talkerChance: 1f, constitutionChance: 1f); // The mission start: the ace takes the human on the first frame, well inside the // window, and nothing may speak yet. @@ -3112,7 +3196,7 @@ FlightController Pursuer(int index, Vector3 at, Vector3 lookAt) { Target = chased, }; - attack.RegisterAi(rig, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + attack.RegisterAi(rig, accentId: accent, talkerChance: 1f, constitutionChance: 1f); rig.PlaceHeld(at, lookAt); return rig; } @@ -3132,7 +3216,7 @@ FlightController Pursuer(int index, Vector3 at, Vector3 lookAt) busy.PlaceHeld(asternAt, chasedAt); busyModes.Enter(AiMode.Pursue, "ordered"); busyModes.NotifyDamage(1e6f, 1e6f, 100f, 100f); - attack.RegisterAi(busy, accentId: 12, talkerChance: 1f, constitutionChance: 1f); + attack.RegisterAi(busy, accentId: accent, talkerChance: 1f, constitutionChance: 1f); attack.Step(0.1f); bool Taunted(FlightController who, int trigger) => calls.Exists(line => @@ -3195,7 +3279,7 @@ FlightController Chaser(int index, int team, Vector3 at) var sixLowAt = homeAt + new Vector3(0f, -200f, 500f); var sixLow = Chaser(FlightRoster.ShooterIdBase + 22, enemyTeam, sixLowAt); wreckWatch.RegisterPlayer(homebound); - wreckWatch.RegisterAi(escort, accentId: 12, talkerChance: 2f, constitutionChance: 1f); + wreckWatch.RegisterAi(escort, accentId: accent, talkerChance: 2f, constitutionChance: 1f); // Accentless, so a death cry cannot occupy the channel the bearing needs. wreckWatch.RegisterAi(sixLow, accentId: null, talkerChance: 0f, constitutionChance: 0f); @@ -3337,6 +3421,20 @@ PlaneStats AiStatsFor(string plane, string? aiDef) engineA.Listeners = () => new[] { posA }; engineB.Listeners = () => new[] { posB }; + // The swap is told apart by the damaged definition the airframe's own record names. The + // install pins the shipped name too. + string? damagedName = aiCache.TryGetValue(ctx.PlaneName, out var shared) ? shared.DamagedEngineSound : null; + ctx.Check(damagedName != null, $"{ctx.PlaneName} names a damaged engine definition ({damagedName ?? "-"})"); + if (damagedName == null) + { + return; + } + if (!ctx.SyntheticData) + { + ctx.Check(damagedName == "snd_damagedengine", + $"{ctx.PlaneName} swaps to the shipped snd_damagedengine ({damagedName})"); + } + const float dt = 1f / 60f; var drive = new EngineDrive(0.5f, 0f, 0f); var damagedA = new List(); @@ -3352,7 +3450,7 @@ PlaneStats AiStatsFor(string plane, string? aiDef) { return; } - bool damaged = line.Contains("snd_damagedengine"); + bool damaged = line.Contains($"-> {damagedName} "); bool fromA = line.Contains(a.Name.ToString()); if (damaged && fromA) damagedA.Add(line); @@ -3800,6 +3898,7 @@ internal static void AiNetFollow(TestContext ctx) ctx.RequireData(texturesPath, $"C1 textures"); string chapterZrdr = SessionPaths.ChapterZrdr(ctx.DataRoot, "C1"); ctx.RequireData(chapterZrdr, $"C1 zrdr"); + ctx.RequireZrdrEntry(chapterZrdr, "neindex.json"); // The chapter graph resolves both ways the data references it: by id (aiv field 0) // and by neindex name (egen/zeppelins/objectives), case-insensitively. diff --git a/CSVM/src/Testing/AirframeColliderSuites.cs b/CSVM/src/Testing/AirframeColliderSuites.cs index ead97db3e..52d50283c 100644 --- a/CSVM/src/Testing/AirframeColliderSuites.cs +++ b/CSVM/src/Testing/AirframeColliderSuites.cs @@ -78,6 +78,7 @@ internal static void AirframeColliderHitRate(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(MarkerRig.PlayerAirframes.Select(a => a.Model).ToArray()); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); @@ -118,6 +119,7 @@ internal static void AirframeHullCoverage(TestContext ctx) ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(MarkerRig.PlayerAirframes.Select(a => a.Model).ToArray()); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var textures = new TextureArchive(texturesPath); var report = new StringBuilder(); diff --git a/CSVM/src/Testing/AirframeSwapSuites.cs b/CSVM/src/Testing/AirframeSwapSuites.cs index 1053150f0..3614247cd 100644 --- a/CSVM/src/Testing/AirframeSwapSuites.cs +++ b/CSVM/src/Testing/AirframeSwapSuites.cs @@ -137,6 +137,7 @@ private static readonly (string Leg, string Twin)[] HangarLegs = internal static void AirframeSwap(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); @@ -176,6 +177,7 @@ internal static void AirframeSwap(TestContext ctx) internal static void CaptureSilencesGuns(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.SoundsPath, $"sound archive (soundsh)"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) @@ -247,6 +249,7 @@ internal static void CaptureSilencesGuns(TestContext ctx) internal static void HangarHandover(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm07Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm07Seq}"); @@ -287,6 +290,7 @@ internal static void HangarHandover(TestContext ctx) internal static void WarhawkCaptureLivery(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm19Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm19Seq}"); diff --git a/CSVM/src/Testing/AnimationAndEffectsSuites.cs b/CSVM/src/Testing/AnimationAndEffectsSuites.cs index eed2c4c1c..4694173a4 100644 --- a/CSVM/src/Testing/AnimationAndEffectsSuites.cs +++ b/CSVM/src/Testing/AnimationAndEffectsSuites.cs @@ -148,15 +148,14 @@ internal static void CrashStageRootTripwire(TestContext ctx, TestWorld world) // ---- contact: the flight ends where the world says ------------------------------------------ - // A gravity-bearing OBJECT_MOTION must be cut short by real geometry instead of running its - // authored RUN_TIME out below the terrain, through whichever of the two tiers its flags select, and - // must pick its BOUNCE_SEQUENCE branch from the surface it struck. Driven as a synthetic body - // thrown downward from a known height, so flight time, resting height and branch are predictable. - // ⚠ Keep the last case, the same bodies with no mask handed over, running their full clock far - // below the surface: without it a suite that fired no query at all would pass its landing checks. - [Suite("ground-contact", - "a gravity-bearing OBJECT_MOTION is cut short by real geometry through the right tier (default column, do_intersections sweep, no_altitude neither), rests on the surface and picks its BOUNCE_SEQUENCE branch from what it struck, and does none of it without a mask")] - internal static void GroundContact(TestContext ctx) + // A gravity-bearing OBJECT_MOTION is cut short by real geometry through the tier its flags select, + // and takes its BOUNCE_SEQUENCE branch from the surface it struck. Each body is built by hand and + // thrown down from a known height over the empty stage, so flight, rest and branch are predictable. + // ⚠ Keep the last case, whose unmasked bodies run their full clock far below the surface. + // Without it, a suite that fired no query would pass every landing check. + [Suite("ground-contact-core", + "a hand-built gravity-bearing OBJECT_MOTION over the empty stage's collider is cut short through the right tier (default column, do_intersections sweep, no_altitude neither), rests on the surface, picks its BOUNCE_SEQUENCE branch from what it struck, resumes a bounce from the landing without the dive's momentum, and does none of it without a mask")] + internal static void GroundContactCore(TestContext ctx) { const float Tick = 1f / 60f; const float Authored = 20f; // the run time these bodies carry; contact must beat it @@ -164,16 +163,17 @@ internal static void GroundContact(TestContext ctx) const string Land = "testhit_ground"; const string Wet = "testhit_water"; - ctx.WithWorld(ctx.Chapter, collision: true, world => - { - var runtime = world.Runtime; - var root = world.Session.Root; + // The motions' owner only keys them in the set and rides a landing to the dispatch this + // suite makes by hand, so any definition serves. + var owner = new AnimDefinition { Name = "ground-contact-owner", AnimName = "ground-contact-owner" }; + WithMotionHost(ctx, "ground-contact-stage", ground: true, body: (root, runtime) => + { // A suite that builds no colliders would pass every contact check by taking the // fallback and proving nothing, so the collision world is asserted before anything // else is asked of it. var space = root.GetWorld3D()?.DirectSpaceState; - ctx.Check(space != null, $"the world built a collision space to sweep against chapter={ctx.Chapter}"); + ctx.Check(space != null, $"the stage built a collision space to sweep against"); if (space == null) { return; @@ -184,7 +184,7 @@ internal static void GroundContact(TestContext ctx) var from = new Vector3(0f, 400f, 0f); var probe = space.IntersectRay(PhysicsRayQueryParameters3D.Create( from, new Vector3(0f, -400f, 0f), CollisionLayers.World)); - ctx.Check(probe.Count > 0, $"a downward probe finds chapter geometry chapter={ctx.Chapter}"); + ctx.Check(probe.Count > 0, $"a downward probe finds the stage's ground"); if (probe.Count == 0) { return; @@ -262,7 +262,7 @@ AnimData Body(bool flagged = true, bool noAltitude = false, bool complex = true, } var set = new MotionSet(); - set.Add(motion, world.Runtime.Destructibles.All.First().Def, null); + set.Add(motion, owner, null); float flown = 0f; string? bounce = null; float cap = limit > 0f ? limit : Authored; @@ -376,51 +376,6 @@ AnimData Body(bool flagged = true, bool noAltitude = false, bool complex = true, ctx.Check(watchdog.Bounce == Land, $"a watchdog end owes the default branch, from its null surface bounce={watchdog.Bounce ?? "(none)"}"); - // The veto on REAL extracted data. Every case above builds its gravity block by hand, which pins - // the branch but not that no_altitude survives extraction and reaches Create at all. gunshell is - // its only author install-wide, and it is reachable because muzzleburst_effects CallAnimations it. - { - var shellDefs = world.Session.Program.ByAnimName("gunshell"); - AnimData? shell = null; - foreach (var def in shellDefs) - { - foreach (var seq in def.Sequences) - { - foreach (var ev in seq.Events) - { - if (ev.Kind == "ObjectMotion" && ev.Data.Obj("translation_range") != null) - { - shell ??= ev.Data; - } - } - } - } - - ctx.Check(shell != null, - $"the chapter program carries gunshell's launch defs={shellDefs.Count} chapter={ctx.Chapter}"); - if (shell != null) - { - ctx.Check(shell.Obj("gravity")?.Bool("no_altitude") == true, - $"and the extracted event still authors no_altitude value={shell.Obj("gravity")?.Bool("no_altitude")}"); - var node = new Node3D { Name = "ground-contact-gunshell" }; - root.AddChild(node); - node.GlobalPosition = new Vector3(0f, surfaceY + DropHeight, 0f); - uint maskWas = runtime.ContactMask; - runtime.ContactMask = CollisionLayers.World; - try - { - var casing = MotionRuntime.Create(runtime, node, shell, shell.Num("run_time") ?? 2f); - ctx.Check(casing is { ContactTier: MotionContactTier.None }, - $"so the one def that opts out selects no tier even with a mask wired tier={casing?.ContactTier}"); - } - finally - { - runtime.ContactMask = maskWas; - node.QueueFree(); - } - } - } - // The bounce is a CONTINUATION: the sequence a landing dispatches re-launches the very node that // landed, and MotionRuntime.Create ordinarily re-homes a ballistic launch to the node's authored // rest pose, which shows as the crash jumping back to the crash point once per piece. @@ -437,7 +392,7 @@ AnimData Body(bool flagged = true, bool noAltitude = false, bool complex = true, { var first = MotionRuntime.Create(runtime, node, Body(), Authored); var set = new MotionSet(); - set.Add(first!, world.Runtime.Destructibles.All.First().Def, null); + set.Add(first!, owner, null); bool landed = false; for (int i = 0; i < (int)(Authored / Tick) + 2 && !first!.Finished; i++) { @@ -545,6 +500,82 @@ float LaunchY(bool impactForce) }); } + // The no_altitude veto on the shipped data. Every case in ground-contact-core builds its gravity + // block by hand, which pins the branch. It does not show that the flag survives extraction and + // reaches Create. The only author install-wide is gunshell, reachable because + // muzzleburst_effects CallAnimations it, so this suite reads the chapter's own program. + [Suite("ground-contact", + "the chapter program's own gunshell launch, the install's one no_altitude author, still carries the flag after extraction and selects no contact tier with the mask wired over a collidable chapter")] + internal static void GroundContact(TestContext ctx) + { + const float DropHeight = 60f; // the height ground-contact-core drops its bodies from + + ctx.WithWorld(ctx.Chapter, collision: true, world => + { + var runtime = world.Runtime; + var root = world.Session.Root; + + // The veto is asked over live colliders, so a world that built none is caught first. + var space = root.GetWorld3D()?.DirectSpaceState; + ctx.Check(space != null, $"the world built a collision space to sweep against chapter={ctx.Chapter}"); + if (space == null) + { + return; + } + + var probe = space.IntersectRay(PhysicsRayQueryParameters3D.Create( + new Vector3(0f, 400f, 0f), new Vector3(0f, -400f, 0f), CollisionLayers.World)); + ctx.Check(probe.Count > 0, $"a downward probe finds chapter geometry chapter={ctx.Chapter}"); + if (probe.Count == 0) + { + return; + } + + float surfaceY = probe["position"].AsVector3().Y; + + // The casing's launch is gunshell's first ranged OBJECT_MOTION. + var shellDefs = world.Session.Program.ByAnimName("gunshell"); + AnimData? shell = null; + foreach (var def in shellDefs) + { + foreach (var seq in def.Sequences) + { + foreach (var ev in seq.Events) + { + if (ev.Kind == "ObjectMotion" && ev.Data.Obj("translation_range") != null) + { + shell ??= ev.Data; + } + } + } + } + + ctx.Check(shell != null, + $"the chapter program carries gunshell's launch defs={shellDefs.Count} chapter={ctx.Chapter}"); + if (shell != null) + { + ctx.Check(shell.Obj("gravity")?.Bool("no_altitude") == true, + $"and the extracted event still authors no_altitude value={shell.Obj("gravity")?.Bool("no_altitude")}"); + var node = new Node3D { Name = "ground-contact-gunshell" }; + root.AddChild(node); + node.GlobalPosition = new Vector3(0f, surfaceY + DropHeight, 0f); + uint maskWas = runtime.ContactMask; + runtime.ContactMask = CollisionLayers.World; + try + { + var casing = MotionRuntime.Create(runtime, node, shell, shell.Num("run_time") ?? 2f); + ctx.Check(casing is { ContactTier: MotionContactTier.None }, + $"so the one def that opts out selects no tier even with a mask wired tier={casing?.ContactTier}"); + } + finally + { + runtime.ContactMask = maskWas; + node.QueueFree(); + } + } + }); + } + // ---- the tumble: a rate about the launch's own perpendicular --------------------------------- // FORWARD_ROTATION turns a launched body about the horizontal PERPENDICULAR of its own launch @@ -557,11 +588,8 @@ float LaunchY(bool impactForce) "an OBJECT_MOTION tumble turns at the authored RATE about its own launch direction's horizontal perpendicular, scaled by that direction's length, a vector-translation launch about its compiled direction, and not at all when that is zero")] internal static void ForwardRotation(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + WithMotionHost(ctx, "forward-rotation-host", (root, runtime) => { - var runtime = world.Runtime; - var root = world.Session.Root; - static Dictionary Vec(float x, float y, float z) => new() { ["x"] = x, ["y"] = y, ["z"] = z }; static Dictionary Range(float v) => @@ -691,11 +719,8 @@ Basis Pose(AnimData data, float t) "a vector-form OBJECT_MOTION's third triple is the compiled launch DIRECTION the tumble reads back, not a random spread: two bodies fly the identical path and end exactly where initial × run_time puts them")] internal static void LaunchDirectionCache(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + WithMotionHost(ctx, "launch-direction-host", (root, runtime) => { - var runtime = world.Runtime; - var root = world.Session.Root; - static Dictionary Vec(Vector3 v) => new() { ["x"] = v.X, ["y"] = v.Y, ["z"] = v.Z }; @@ -748,6 +773,31 @@ Vector3 Fly(AnimData data, float t) }); } + // The whole host a hand-built OBJECT_MOTION with no gravity reads: a parent node, and a bare + // runtime for its rest pose, RNG and contact mask. A chapter world adds nothing such a body + // touches, so a suite on this host runs without an install. With ground, the parent is the + // empty stage's collidable grid, whose top face is y = 0. + // ⚠ Do not run a contact case here without setting the mask: it stays 0, so every gravity body selects no tier. + internal static void WithMotionHost(TestContext ctx, string name, System.Action body, + bool ground = false) + { + var root = ground ? EmptyStage.Build(collision: true).Root : new Node3D(); + root.Name = name; + var runtime = new AnimRuntime { AutoStart = false, ManualAdvance = true }; + ctx.Host.AddChild(root); + ctx.Host.AddChild(runtime); + try + { + runtime.Bind(root, new AnimProgram()); + body(root, runtime); + } + finally + { + runtime.Free(); + root.Free(); + } + } + // ---- a chain of OBJECT_MOTION events on one placed node: the Barracuda's drive ------------- // C3/M03's `sub_movement` is the install's clearest chain: a reset placement 17.8 km from the @@ -1372,12 +1422,12 @@ internal static void CollectNamed(Node node, Dictionary into) "a WAIT_FOR_COMPLETION call holds the caller's next event for its callee, and an unflagged one beside it does not (BL-228)")] internal static void WaitForCompletion(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { const string animName = "player_crash_water"; const string flagged = "plane_big_splash"; const string held = "large_steam_spray"; - var program = world.Session.Program.Subset(animName); + var program = source.Program.Subset(animName); var defs = program.ByAnimName(animName); ctx.Check(defs.Count > 0, $"chapter program has {animName} defs={defs.Count}"); if (defs.Count == 0) @@ -1465,18 +1515,18 @@ internal static void WaitForCompletion(TestContext ctx) "a host going inactive spares the emitter that started in its own instant and still ends the one that did not (BL-229)")] internal static void EmitterHostDeactivation(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { - SplashSurvivesItsOwnInstant(ctx, world); - DebrisTrailStillEndsWithItsHost(ctx, world); + SplashSurvivesItsOwnInstant(ctx, source); + DebrisTrailStillEndsWithItsHost(ctx, source); }); } - internal static void SplashSurvivesItsOwnInstant(TestContext ctx, TestWorld world) + internal static void SplashSurvivesItsOwnInstant(TestContext ctx, AnimSource source) { const string animName = "plane_big_splash"; const string pufferName = "splasher"; - var program = world.Session.Program.Subset(animName); + var program = source.Program.Subset(animName); var defs = program.ByAnimName(animName); ctx.Check(defs.Count > 0, $"chapter program has {animName} defs={defs.Count}"); if (defs.Count == 0) @@ -1515,13 +1565,13 @@ internal static void SplashSurvivesItsOwnInstant(TestContext ctx, TestWorld worl asCrashRig: true); } - internal static void DebrisTrailStillEndsWithItsHost(TestContext ctx, TestWorld world) + internal static void DebrisTrailStillEndsWithItsHost(TestContext ctx, AnimSource source) { const string animName = "m_build01"; const string pufferName = "trailpuffer3"; const string host = "part3"; const string offSequence = "sparkout3"; // where part3's own deactivation is authored - var program = world.Session.Program.Subset(animName); + var program = source.Program.Subset(animName); var defs = program.ByAnimName(animName); ctx.Check(defs.Count > 0, $"chapter program has {animName} defs={defs.Count}"); if (defs.Count == 0) @@ -1648,16 +1698,16 @@ internal static void WithEmitterStage(TestContext ctx, AnimProgram program, stri "an effect's template meshes show at the call site (including a CALLED template's) and go dark when it ends (BL-061)")] internal static void EffectTemplateMesh(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { - CalledTemplateShowsItsMesh(ctx, world); - EndedEffectLeavesNoMeshLit(ctx, world); + CalledTemplateShowsItsMesh(ctx, source); + EndedEffectLeavesNoMeshLit(ctx, source); }); } - internal static void CalledTemplateShowsItsMesh(TestContext ctx, TestWorld world) + internal static void CalledTemplateShowsItsMesh(TestContext ctx, AnimSource source) { - EffectStageSuiteHelper.WithEffectStage(ctx, world, "he_ground_effect", new[] { "he_ring", "he_ring1", "he_trails" }, + EffectStageSuiteHelper.WithEffectStage(ctx, source, "he_ground_effect", new[] { "he_ring", "he_ring1", "he_trails" }, (stage, runtime, point) => { ctx.Check(Probes.MeshCensus.VisibleMeshes(stage) == 0, @@ -1678,9 +1728,9 @@ internal static void CalledTemplateShowsItsMesh(TestContext ctx, TestWorld world }); } - internal static void EndedEffectLeavesNoMeshLit(TestContext ctx, TestWorld world) + internal static void EndedEffectLeavesNoMeshLit(TestContext ctx, AnimSource source) { - EffectStageSuiteHelper.WithEffectStage(ctx, world, "3040ap_gunhit", new[] { "dum_gunhit" }, (stage, runtime, point) => + EffectStageSuiteHelper.WithEffectStage(ctx, source, "3040ap_gunhit", new[] { "dum_gunhit" }, (stage, runtime, point) => { runtime.PlayEffectAt("3040ap_gunhit", point, null, 0.3f); runtime.Advance(1f / 60f); diff --git a/CSVM/src/Testing/BalmoralCaptureSuites.cs b/CSVM/src/Testing/BalmoralCaptureSuites.cs index 2ea59909d..fff319da8 100644 --- a/CSVM/src/Testing/BalmoralCaptureSuites.cs +++ b/CSVM/src/Testing/BalmoralCaptureSuites.cs @@ -63,6 +63,7 @@ internal static class BalmoralCaptureSuites internal static void Cm15Capture(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), MissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {MissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -102,6 +103,7 @@ internal static void Cm15Capture(TestContext ctx) internal static void Cm15BalmoralHidden(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), OtherMissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {OtherMissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -140,6 +142,7 @@ internal static void Cm15BalmoralHidden(TestContext ctx) internal static void Cm15StagedPaint(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), MissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {MissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/BotContactSuites.cs b/CSVM/src/Testing/BotContactSuites.cs index 21f6bb704..c8337e7c2 100644 --- a/CSVM/src/Testing/BotContactSuites.cs +++ b/CSVM/src/Testing/BotContactSuites.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CSVM.Extraction; using CSVM.Flight.Airframe; using CSVM.Flight.Camera; @@ -67,6 +68,7 @@ internal static void GrazeBounceBot(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(StockAirframes.Nodes.ToArray()); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var textures = new TextureArchive(texturesPath); diff --git a/CSVM/src/Testing/CampaignBalloonDeathSuites.cs b/CSVM/src/Testing/CampaignBalloonDeathSuites.cs index 88d88eb44..c8dd44041 100644 --- a/CSVM/src/Testing/CampaignBalloonDeathSuites.cs +++ b/CSVM/src/Testing/CampaignBalloonDeathSuites.cs @@ -61,6 +61,7 @@ internal static class CampaignBalloonDeathSuites internal static void CampaignBalloonDeath(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), BalloonSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {BalloonSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/CampaignBriefingNoteSuites.cs b/CSVM/src/Testing/CampaignBriefingNoteSuites.cs index 25cbe5e61..9cb6cafb7 100644 --- a/CSVM/src/Testing/CampaignBriefingNoteSuites.cs +++ b/CSVM/src/Testing/CampaignBriefingNoteSuites.cs @@ -41,6 +41,7 @@ internal static class CampaignBriefingNoteSuites internal static void CampaignBriefingNote(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var probe = new Control(); ctx.Host.AddChild(probe); try diff --git a/CSVM/src/Testing/CampaignBriefingRepaintSuites.cs b/CSVM/src/Testing/CampaignBriefingRepaintSuites.cs index 68251ccbd..c885cf02c 100644 --- a/CSVM/src/Testing/CampaignBriefingRepaintSuites.cs +++ b/CSVM/src/Testing/CampaignBriefingRepaintSuites.cs @@ -38,6 +38,7 @@ internal static class CampaignBriefingRepaintSuites internal static void CampaignBriefingRepaint(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, Mech3.CampaignSequence.FileName); var menu = MenuSuiteHost.Menu(ctx, "campaign-briefing-repaint"); ctx.Host.AddChild(menu); try diff --git a/CSVM/src/Testing/CampaignHudSuites.cs b/CSVM/src/Testing/CampaignHudSuites.cs index 1efcebfae..8068341b1 100644 --- a/CSVM/src/Testing/CampaignHudSuites.cs +++ b/CSVM/src/Testing/CampaignHudSuites.cs @@ -46,14 +46,18 @@ private enum CompletionRoute "COMPLETED_SOUND_GROUP, one of its groups reaches a real player (BL-483)")] internal static void CampaignObjectivesHud(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); + ctx.RequireData(ctx.MessagesPath, $"messages file"); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (FirstMissionOf(missions, ctx.Chapter) is not { } mission) { throw new SuiteSkippedException($"chapter {ctx.Chapter} holds no campaign mission"); } - var script = ObjectiveScript.Load( - SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder)); + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder); + ctx.RequireData(missionZrdr, $"{mission.ChapterFolder}/{mission.MissionFolder} zrdr"); + var script = ObjectiveScript.Load(missionZrdr); var messages = Messages.Load(ctx.MessagesPath); var profile = CampaignProfileDef.NewProfile("Zachary"); var director = CampaignDirector.Create(script, mission, profile, null); @@ -146,14 +150,17 @@ internal static void CampaignObjectivesHud(TestContext ctx) + "for its length and nobody else")] internal static void MissionRadioCallouts(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (FirstMissionOf(missions, ctx.Chapter) is not { } mission) { throw new SuiteSkippedException($"chapter {ctx.Chapter} holds no campaign mission"); } - var script = ObjectiveScript.Load( - SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder)); + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder); + ctx.RequireData(missionZrdr, $"{mission.ChapterFolder}/{mission.MissionFolder} zrdr"); + var script = ObjectiveScript.Load(missionZrdr); var defs = SoundDefs.Load(ctx.ZrdrPath); var groups = SoundDefs.LoadGroups(ctx.ZrdrPath); var report = new StringBuilder(); diff --git a/CSVM/src/Testing/CampaignLayoutSuites.cs b/CSVM/src/Testing/CampaignLayoutSuites.cs index a1c44d4d5..be4731c97 100644 --- a/CSVM/src/Testing/CampaignLayoutSuites.cs +++ b/CSVM/src/Testing/CampaignLayoutSuites.cs @@ -10,11 +10,12 @@ namespace CSVM.Testing; /// /// The campaign boards over the decoded layout compose the same screen the hardcoded chrome -/// composes: every scratch-profile aid is opened twice through a real , -/// once with the flow pinned to and once reading the data -/// root's own menu_layout.json, and the two composed boards are compared element by element. -/// The decoded side must have loaded, and the rows the boards pin to a measurement must really -/// differ from it, or the comparison would be the fallback against itself. +/// composes. Every scratch-profile aid is opened twice through a real , +/// pinned to and then over the root's menu_layout.json. +/// The two composed boards are compared element by element. The decoded side must have loaded, and +/// its measured rows must differ from the fallback's, or the comparison would be the fallback +/// against itself. That the two sources are read holds on any layout and is the core suite's; the +/// comparison needs the shipped one. /// internal static class CampaignLayoutSuites { @@ -28,6 +29,47 @@ private static readonly (string Aid, int Join)[] Aids = ("campaign-planeselection", 0), ("campaign-planeselection:export", 0), }; + [Suite("campaign-layout-core", + "every campaign screenshot aid composed twice through a real LaunchMenu, once with the flow " + + "pinned to the hardcoded chrome and once over the data root's decoded menu layout: the " + + "decoded layout loads, the pinned composition reads the hardcoded chrome and the other the " + + "data root's layout, and each source composes a board")] + internal static void CampaignLayoutCore(TestContext ctx) + { + ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); + var decoded = Loaded(ctx); + if (decoded == null) + { + return; + } + + var menu = MenuSuiteHost.Menu(ctx, "campaign-layout-core"); + ctx.Host.AddChild(menu); + menu.SetProcess(false); + try + { + foreach (var (aid, join) in Aids) + { + menu.CampaignLayoutOverride = CampaignLayout.Fallback; + string hardcoded = Compose(menu, aid, join); + bool pinned = menu.Campaign?.Layout == CampaignLayout.Fallback; + menu.CampaignLayoutOverride = null; + string read = Compose(menu, aid, join); + bool fromRoot = menu.Campaign?.Layout == decoded; + ctx.Check(pinned, $"{aid}: the pinned composition reads the hardcoded chrome"); + ctx.Check(fromRoot, $"{aid}: the second composition reads the data root's layout"); + ctx.Check(hardcoded.Length > 0 && read.Length > 0, + $"{aid}: each source composes a board ({hardcoded.Split('\n').Length} / {read.Split('\n').Length} lines)"); + } + } + finally + { + menu.CampaignLayoutOverride = null; + ctx.Host.RemoveChild(menu); + menu.QueueFree(); + } + } + [Suite("campaign-layout-parity", "every campaign screenshot aid composed twice through a real LaunchMenu, once over the " + "hardcoded chrome and once over the decoded menu layout, and the two composed boards " @@ -38,10 +80,15 @@ internal static void CampaignLayoutParity(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); - var decoded = CampaignLayout.For(ctx.DataRoot); - ctx.Check(decoded.Decoded != null && decoded.Reason == null, - $"the data root's layout loaded ({decoded.Reason ?? "no reason"})"); - if (decoded.Decoded == null) + // ⚠ Never skip on a real extraction. The hardcoded chrome copies the shipped layout, so an + // invented one has nothing to match, and campaign-layout-core reads it instead. + if (ctx.SyntheticData) + { + throw new SuiteSkippedException($"the synthetic tree's layout is invented, and the hardcoded chrome copies the shipped one"); + } + + var decoded = Loaded(ctx); + if (decoded == null) { return; } @@ -59,8 +106,6 @@ internal static void CampaignLayoutParity(TestContext ctx) string hardcoded = Compose(menu, aid, join); menu.CampaignLayoutOverride = null; string read = Compose(menu, aid, join); - bool fromRoot = menu.Campaign?.Layout == decoded; - ctx.Check(fromRoot, $"{aid}: the second composition reads the data root's layout"); ctx.Check(hardcoded.Length > 0 && hardcoded == read, $"{aid}: the board over the decoded layout is the board over the hardcoded chrome ({FirstDifference(hardcoded, read)})"); report.Append("== ").Append(aid).Append(" (").Append(hardcoded.Split('\n').Length.ToString(CultureInfo.InvariantCulture)) @@ -77,6 +122,15 @@ internal static void CampaignLayoutParity(TestContext ctx) ctx.WriteArtifact("campaign-layout-parity.txt", report.ToString()); } + // The data root's layout as the campaign flow reads it, or null after the failed check. + private static CampaignLayout? Loaded(TestContext ctx) + { + var decoded = CampaignLayout.For(ctx.DataRoot); + ctx.Check(decoded.Decoded != null && decoded.Reason == null, + $"the data root's layout loaded ({decoded.Reason ?? "no reason"})"); + return decoded.Decoded != null ? decoded : null; + } + // The six values docs/org/campaign-board.md pins: each must still differ from its row, or the // pin is dead and the board has two sources for one pixel. private static void PinnedRowsDiffer(TestContext ctx, CampaignLayout layout) diff --git a/CSVM/src/Testing/CampaignLoopSuites.cs b/CSVM/src/Testing/CampaignLoopSuites.cs index 0ae2345d2..53ea7fc0f 100644 --- a/CSVM/src/Testing/CampaignLoopSuites.cs +++ b/CSVM/src/Testing/CampaignLoopSuites.cs @@ -74,6 +74,7 @@ internal static class CampaignLoopSuites internal static void CampaignLoop(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), FirstSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {FirstSeq}"); diff --git a/CSVM/src/Testing/CampaignMarkerSuites.cs b/CSVM/src/Testing/CampaignMarkerSuites.cs index 12296ea6c..efe1b8ac7 100644 --- a/CSVM/src/Testing/CampaignMarkerSuites.cs +++ b/CSVM/src/Testing/CampaignMarkerSuites.cs @@ -106,6 +106,7 @@ private static readonly (string Key, string Line1, string Line2, bool Red)[] Lab internal static void CampaignObjectiveMarkers(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), FirstSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {FirstSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -146,6 +147,7 @@ internal static void CampaignObjectiveMarkers(TestContext ctx) internal static void CampaignObjectiveTargetPath(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), PathSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {PathSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -195,6 +197,7 @@ internal static void CampaignObjectiveTargetPath(TestContext ctx) internal static void CampaignObjectiveLabels(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), LabelSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {LabelSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -255,6 +258,7 @@ internal static void CampaignObjectiveLabels(TestContext ctx) internal static void CampaignBalloonMarker(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), BalloonSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {BalloonSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/CampaignPlaneChangeSuites.cs b/CSVM/src/Testing/CampaignPlaneChangeSuites.cs index 2ad8fe1b0..223ae3352 100644 --- a/CSVM/src/Testing/CampaignPlaneChangeSuites.cs +++ b/CSVM/src/Testing/CampaignPlaneChangeSuites.cs @@ -44,6 +44,7 @@ internal static class CampaignPlaneChangeSuites internal static void CampaignFlightCheckPlaneChange(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var report = new StringBuilder(); var reward = CheckAuthored(ctx, ctx.ZrdrPath, report); string root = Path.Combine(ctx.ScratchDir, "campaign-plane-change"); diff --git a/CSVM/src/Testing/CampaignRaceSuites.cs b/CSVM/src/Testing/CampaignRaceSuites.cs index d3e6ccbaa..8e1d7d14a 100644 --- a/CSVM/src/Testing/CampaignRaceSuites.cs +++ b/CSVM/src/Testing/CampaignRaceSuites.cs @@ -59,6 +59,7 @@ internal static class CampaignRaceSuites internal static void CampaignRaceChain(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionAt(CampaignSequence.Load(ctx.ZrdrPath)) ?? throw new SuiteSkippedException($"{Chapter}/{Mission} is not in cm_sequence"); string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, Chapter, Mission); diff --git a/CSVM/src/Testing/CampaignSnapshotSuites.cs b/CSVM/src/Testing/CampaignSnapshotSuites.cs index f4c1c934b..358979636 100644 --- a/CSVM/src/Testing/CampaignSnapshotSuites.cs +++ b/CSVM/src/Testing/CampaignSnapshotSuites.cs @@ -64,6 +64,7 @@ internal static class CampaignSnapshotSuites internal static void CampaignDangerZoneSnapshot(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); if (MissionAt(CampaignSequence.Load(ctx.ZrdrPath)) is not { } mission) { throw new SuiteSkippedException($"{Chapter}/{Mission} is not in cm_sequence"); diff --git a/CSVM/src/Testing/CampaignSuites.cs b/CSVM/src/Testing/CampaignSuites.cs index b63c3ba7f..45adb0e1a 100644 --- a/CSVM/src/Testing/CampaignSuites.cs +++ b/CSVM/src/Testing/CampaignSuites.cs @@ -109,6 +109,8 @@ internal static class CampaignSuites + "that could have wrecked them")] internal static void CampaignPersistence(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); var second = LastMissionOf(missions, ctx.Chapter); if (second is not { } later @@ -267,6 +269,8 @@ internal static void CampaignPersistence(TestContext ctx) + "same world built from the bootstrap alone has it whole")] internal static void PersistChainKill(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (MissionAt(missions, BridgeChapter, BridgeMission) is not { } dropped || MissionAt(missions, BridgeChapter, BridgeLaterMission) is not { } later) @@ -456,14 +460,17 @@ internal static void CampaignObjectives(TestContext ctx) + "view")] internal static void CampaignMissionEnd(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (FirstMissionOf(missions, ctx.Chapter) is not { } mission) { throw new SuiteSkippedException($"chapter {ctx.Chapter} holds no campaign mission"); } - var script = ObjectiveScript.Load( - SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder)); + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder); + ctx.RequireData(missionZrdr, $"{mission.ChapterFolder}/{mission.MissionFolder} zrdr"); + var script = ObjectiveScript.Load(missionZrdr); if (script.Objectives.Count == 0) { throw new SuiteSkippedException($"{mission.ChapterFolder}/{mission.MissionFolder} authors no objectives"); @@ -537,6 +544,7 @@ internal static void CampaignCoopAttempt(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (FirstMissionOf(missions, ctx.Chapter) is not { } mission) { @@ -669,6 +677,8 @@ void FireAt(Flight.Airframe.FlightController shooter, Vector3 at) + "both read it, and a replay of the same objective banks nothing")] internal static void CampaignMissionCash(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (MissionAt(missions, CashChapter, CashMission) is not { } mission) { @@ -758,6 +768,8 @@ internal static void CampaignMissionCash(TestContext ctx) + "SECONDARY (OBJECTIVE3) and OBJECTIVE11 the way a flown mission would")] internal static void CampaignDangerZoneObjectives(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (MissionAt(missions, DangerZoneChapter, DangerZoneMission) is not { } mission) { @@ -765,6 +777,7 @@ internal static void CampaignDangerZoneObjectives(TestContext ctx) } string missionZrdrPath = SessionPaths.MissionZrdr(ctx.DataRoot, mission.ChapterFolder, mission.MissionFolder); + ctx.RequireData(missionZrdrPath, $"{DangerZoneChapter}/{DangerZoneMission} zrdr"); var script = ObjectiveScript.Load(missionZrdrPath); var secondary = script.Objectives.Find(d => d.DangerZones.Contains("dzpath1")); var worker = script.Objectives.Find(d => d.DangerZones.Contains("dzpath4")); diff --git a/CSVM/src/Testing/CampaignTargetListSuites.cs b/CSVM/src/Testing/CampaignTargetListSuites.cs index 7ca133d4d..dce770601 100644 --- a/CSVM/src/Testing/CampaignTargetListSuites.cs +++ b/CSVM/src/Testing/CampaignTargetListSuites.cs @@ -75,6 +75,7 @@ private static readonly (int Ordinal, string Chapter, string Mission)[] Flown = internal static void CampaignTargetList(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var report = new StringBuilder(); var missions = CampaignSequence.Load(ctx.ZrdrPath); ctx.Same(CampaignSequence.MissionCount, missions.Count, diff --git a/CSVM/src/Testing/CampaignZeppelinSuites.cs b/CSVM/src/Testing/CampaignZeppelinSuites.cs index 8bf98efb7..29be8bfa3 100644 --- a/CSVM/src/Testing/CampaignZeppelinSuites.cs +++ b/CSVM/src/Testing/CampaignZeppelinSuites.cs @@ -34,6 +34,7 @@ internal static class CampaignZeppelinSuites internal static void CampaignZeppelins(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); var mission = MissionOf(missions, ZepChapter, ZepMission) ?? throw new SuiteSkippedException($"{ZepChapter}/{ZepMission} is not in cm_sequence"); diff --git a/CSVM/src/Testing/CampaignZeppelinWakeSuites.cs b/CSVM/src/Testing/CampaignZeppelinWakeSuites.cs index da87bbab5..964d4f1a5 100644 --- a/CSVM/src/Testing/CampaignZeppelinWakeSuites.cs +++ b/CSVM/src/Testing/CampaignZeppelinWakeSuites.cs @@ -42,6 +42,7 @@ internal static class CampaignZeppelinWakeSuites internal static void CampaignZeppelinWakeup(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), FirstSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {FirstSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/CaptureChuteSuites.cs b/CSVM/src/Testing/CaptureChuteSuites.cs index 6ee679509..606b0e436 100644 --- a/CSVM/src/Testing/CaptureChuteSuites.cs +++ b/CSVM/src/Testing/CaptureChuteSuites.cs @@ -51,6 +51,7 @@ internal static class CaptureChuteSuites internal static void CaptureChutes(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = WingWalkCameraSuites.MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); diff --git a/CSVM/src/Testing/CombatSuites.cs b/CSVM/src/Testing/CombatSuites.cs index 512c9cc94..239b09113 100644 --- a/CSVM/src/Testing/CombatSuites.cs +++ b/CSVM/src/Testing/CombatSuites.cs @@ -30,6 +30,7 @@ internal static void LoadoutBind(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane(MarkerRig.PlayerAirframes.Select(a => a.Model).ToArray()); var r = Probes.Loadouts(ctx.ZrdrPath, ctx.MessagesPath, ctx.PlanesGamezPath, ctx.DataRoot, "", ctx.LoadoutOverride); ctx.Check(r.Error == null, $"loadout inputs load error={r.Error ?? "-"}"); @@ -237,8 +238,15 @@ internal static void WeaponsFire(TestContext ctx) pool = new ProjectilePool(textures, null, null); ctx.Host.AddChild(pool); var result = WeaponBench.Run(plane, loadout, weapons, pool); - ctx.Same(WeaponDefCount, result.Total, $"weapons offered to the bench"); - ctx.Same(WeaponDefCount, result.Ok, $"weapons that mounted and fired"); + // Every weapon the loaded table defines mounts and fires. The bench is offered the whole + // table, so only the install's census can pin how many that is. + ctx.Check(result.Total > 0 && result.Ok == result.Total, + $"weapons that mounted and fired, every one of the {result.Total} the table defines ok={result.Ok}"); + if (!ctx.SyntheticData) + { + ctx.Same(WeaponDefCount, result.Total, $"weapons offered to the bench"); + ctx.Same(WeaponDefCount, result.Ok, $"weapons that mounted and fired"); + } ctx.Same(0, result.Errors, $"weapons that threw"); // ForRig seats 4 gun-group slots on every airframe (loadout-forrig proves that across // all 11); the pylon count is per-rig, so only its presence is pinned here, 0 pylons @@ -406,22 +414,46 @@ internal static void AimAssistShippedData(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); - ctx.Check(Mathf.IsEqualApprox(5.0f, stats.StickyBulletCatchupRate), - $"sticky_bullet_catchup_rate parses as the shipped 5.0"); - ctx.Check(Mathf.IsEqualApprox(1.5f, stats.StickyBulletForgetInterval), - $"sticky_bullet_forget_interval parses as the shipped 1.5"); - ctx.Check(stats.StickyBulletDistFactor == 0f, - $"sticky_bullet_dist_factor parses as the shipped 0.0 (compiled default 2.5e-4), so selection is purely most-aligned"); - ctx.Check(Mathf.IsEqualApprox(1.0f, Mathf.RadToDeg(stats.StickyBulletInaccuracy), 1e-4f), - $"sticky_bullet_inaccuracy parses as the shipped 1.0 DEGREE, stored in radians as the original stores it"); + // Each field against its own record. A reader that missed the key would hold the compiled + // fallback, which shows wherever the record authors anything else. + var fields = new (string Key, float Field)[] + { + ("sticky_bullet_catchup_rate", stats.StickyBulletCatchupRate), + ("sticky_bullet_forget_interval", stats.StickyBulletForgetInterval), + ("sticky_bullet_dist_factor", stats.StickyBulletDistFactor), + ("sticky_bullet_inaccuracy", Mathf.RadToDeg(stats.StickyBulletInaccuracy)), + }; + foreach (var (key, field) in fields) + { + float? authored = SuiteConstants.PlayerGlobal(ctx.ZrdrPath, key); + ctx.Check(authored is { } a && Mathf.IsEqualApprox(field, a, 1e-6f), + $"{key} parses as player.json authors it ({authored?.ToString("0.#####", System.Globalization.CultureInfo.InvariantCulture) ?? "absent"}): got {field:0.#####}"); + } + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(5.0f, stats.StickyBulletCatchupRate), + $"sticky_bullet_catchup_rate parses as the shipped 5.0"); + ctx.Check(Mathf.IsEqualApprox(1.5f, stats.StickyBulletForgetInterval), + $"sticky_bullet_forget_interval parses as the shipped 1.5"); + ctx.Check(stats.StickyBulletDistFactor == 0f, + $"sticky_bullet_dist_factor parses as the shipped 0.0 (compiled default 2.5e-4), so selection is purely most-aligned"); + ctx.Check(Mathf.IsEqualApprox(1.0f, Mathf.RadToDeg(stats.StickyBulletInaccuracy), 1e-4f), + $"sticky_bullet_inaccuracy parses as the shipped 1.0 DEGREE, stored in radians as the original stores it"); + } var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); var gun = weapons.All.FirstOrDefault(w => w.IsGun && w.CannonSpread is > 0f); ctx.Check(gun != null, $"a gun ships CANNON_SPREAD, the assist's acceptance cone (BL-342 A1)"); if (gun != null) { - ctx.Check(Mathf.IsEqualApprox(6.0f, gun.CannonSpread!.Value), - $"{gun.Id} ships CANNON_SPREAD 6.0, a 6° cone half-angle: cos={AimAssist.WeaponConeCos(gun):0.000000}"); + float spread = gun.CannonSpread!.Value; + ctx.Check(Mathf.IsEqualApprox(AimAssist.WeaponConeCos(gun), Mathf.Cos(Mathf.DegToRad(spread))), + $"{gun.Id}'s CANNON_SPREAD {spread:0.#} is the assist's cone half-angle in degrees: cos={AimAssist.WeaponConeCos(gun):0.000000}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(6.0f, spread), + $"{gun.Id} ships CANNON_SPREAD 6.0, a 6° cone half-angle: cos={AimAssist.WeaponConeCos(gun):0.000000}"); + } } } @@ -772,6 +804,7 @@ internal static void LoadoutForRig(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(MarkerRig.PlayerAirframes.Select(a => a.Model).ToArray()); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var weapons = WeaponDefs.Load(ctx.ZrdrPath, Messages.Load(ctx.MessagesPath)); @@ -861,6 +894,7 @@ internal static void TrailWorldAnchor(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "pufftrails.json"); var textures = new TextureArchive(texturesPath); Node3D? carrier = null; try @@ -1121,6 +1155,7 @@ ProjectilePool Fire(WorldLights? lights) internal static void TurretDeathEffectWorldAnchor(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); var turretDefs = TurretDefs.Load(ctx.ZrdrPath); ProjectilePool? pool = null; @@ -1433,8 +1468,22 @@ internal static void IncomingFireCues(TestContext ctx) var skills = AiSkills.Load(ctx.ZrdrPath); var stock = StockLoadouts.Load().All.Values.FirstOrDefault(d => d.Model == ctx.PlaneName); ctx.Check(stock != null, $"stock loadout found for plane={ctx.PlaneName}"); - ctx.Check(stats.BulletHitSound == "bullet_hit_sg", - $"player.json binds bullet_hit_sound={stats.BulletHitSound}, the group the ricochet draws from"); + // Each cue is judged by the members of its own group as the loaded records name them. Two + // groups are the ones player.json binds, the third the canopy group the code names. The + // install pins the shipped names too. + string[] Members(string group) => soundGroups.TryGetValue(group, out var g) + ? g.Members.Select(m => m.Name).ToArray() : System.Array.Empty(); + bool DrewFrom(string line, string[] members) => members.Any(m => line.Contains($"snd={m}")); + string[] passMembers = Members(stats.WarningShotSound); + string[] ricochetMembers = Members(stats.BulletHitSound); + string[] windowMembers = Members(Flight.Hud.CanopyHoleCue.WindowHitSound); + ctx.Check(passMembers.Length > 0 && ricochetMembers.Length > 0 && windowMembers.Length > 0, + $"player.json binds warning_shot_sound={stats.WarningShotSound} and bullet_hit_sound={stats.BulletHitSound}, groups with members as {Flight.Hud.CanopyHoleCue.WindowHitSound} has ({passMembers.Length}/{ricochetMembers.Length}/{windowMembers.Length})"); + if (!ctx.SyntheticData) + { + ctx.Check(stats.BulletHitSound == "bullet_hit_sg", + $"player.json binds bullet_hit_sound={stats.BulletHitSound}, the group the ricochet draws from"); + } if (stock == null) return; @@ -1526,8 +1575,13 @@ internal static void IncomingFireCues(TestContext ctx) string name = PilotView.Name(view); ctx.Check(passed.Count > 0, $"{name}: the AI gunner's first rounds reached the shield passes={passed.Count}"); - ctx.Check(passed.All(l => l.Contains("snd=snd_bulletpass")), - $"{name}: every absorbed round drew from bullet_warning_sg's own members first={passed.FirstOrDefault() ?? "-"}"); + ctx.Check(passed.All(l => DrewFrom(l, passMembers)), + $"{name}: every absorbed round drew from {stats.WarningShotSound}'s own members first={passed.FirstOrDefault() ?? "-"}"); + if (!ctx.SyntheticData) + { + ctx.Check(passed.All(l => l.Contains("snd=snd_bulletpass")), + $"{name}: every absorbed round drew from bullet_warning_sg's own members first={passed.FirstOrDefault() ?? "-"}"); + } ctx.Check(Mathf.IsEqualApprox(shielded, before), $"{name}: not one point of gun damage stuck while the shield stood ({before:0.0} → {shielded:0.0})"); ctx.Check(hits.FindIndex(l => l.Contains("warning shot P1")) @@ -1535,8 +1589,13 @@ internal static void IncomingFireCues(TestContext ctx) $"{name}: the pass cue comes first and the ricochet only after the accumulator filled"); ctx.Check(landed > 0, $"{name}: past saturation the same gunner's rounds tell rounds={landed}"); ctx.Same(landed, rung.Count, $"{name}: every landed gun round rang the ricochet"); - ctx.Check(rung.Count > 0 && rung.All(l => l.Contains("snd=snd_ricochet")), - $"{name}: every draw came from bullet_hit_sg's own members first={rung.FirstOrDefault() ?? "-"}"); + ctx.Check(rung.Count > 0 && rung.All(l => DrewFrom(l, ricochetMembers)), + $"{name}: every draw came from {stats.BulletHitSound}'s own members first={rung.FirstOrDefault() ?? "-"}"); + if (!ctx.SyntheticData) + { + ctx.Check(rung.Count > 0 && rung.All(l => l.Contains("snd=snd_ricochet")), + $"{name}: every draw came from bullet_hit_sg's own members first={rung.FirstOrDefault() ?? "-"}"); + } ctx.Check(Dented(), $"{name}: and the rounds that got through reached the hull pool health={target.Damage!.SummaryHealthFraction:0.000}"); @@ -1553,8 +1612,13 @@ internal static void IncomingFireCues(TestContext ctx) } var glass = hits.Where(l => l.Contains("canopy hole P1")).ToList(); - ctx.Check(glass.Count == 1 && glass[0].Contains("snd=snd_windowhit"), - $"{name}: the canopy cue opened a hole and drew from window_hit_sg line={glass.FirstOrDefault() ?? "-"}"); + ctx.Check(glass.Count == 1 && DrewFrom(glass[0], windowMembers), + $"{name}: the canopy cue opened a hole and drew from {Flight.Hud.CanopyHoleCue.WindowHitSound}'s own members line={glass.FirstOrDefault() ?? "-"}"); + if (!ctx.SyntheticData) + { + ctx.Check(glass.Count == 1 && glass[0].Contains("snd=snd_windowhit"), + $"{name}: the canopy cue opened a hole and drew from window_hit_sg line={glass.FirstOrDefault() ?? "-"}"); + } report.Add($"{name}: {passed.Count} rounds absorbed, {landed} landed, {rung.Count} ricochets, {glass.Count} canopy hole(s)"); } @@ -1775,8 +1839,14 @@ internal static void GrazeBounce(TestContext ctx) var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); - ctx.Check(Mathf.IsEqualApprox(stats.BounceFactor, 0.6f), - $"the airframe carries this install's authored bounce_factor={stats.BounceFactor:0.###}"); + float? authoredBounce = SuiteConstants.PlayerGlobal(ctx.ZrdrPath, "bounce_factor", "crash"); + ctx.Check(authoredBounce is { } bounce && Mathf.IsEqualApprox(stats.BounceFactor, bounce), + $"the airframe carries player.json's crash bounce_factor={authoredBounce?.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) ?? "absent"} got={stats.BounceFactor:0.###}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(stats.BounceFactor, 0.6f), + $"the airframe carries this install's authored bounce_factor={stats.BounceFactor:0.###}"); + } var textures = new TextureArchive(texturesPath); StaticBody3D? surface = null; @@ -1919,6 +1989,7 @@ internal static void GrazeBounce(TestContext ctx) internal static void AiPlaneDefs(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane(MarkerRig.PlayerAirframes.Select(a => a.Model).ToArray()); // node name, AI def, authored pair, def-level injure entries. The pools are vehicle.json's // own (docs/formats/vehicle.md); the ladder is seven everywhere but the balmoral's eight. @@ -2164,7 +2235,7 @@ internal static void BlastNeighborShape(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_14", out var torpedo) + if (OrdnanceSuites.PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo || torpedo.HealthDamage is not > 0f || torpedo.ImpactProximity is not > 0f) { ctx.Check(false, $"torpedo (wep_14) carries HEALTH_DAMAGE + IMPACT_PROXIMITY"); @@ -2258,6 +2329,7 @@ internal static void BlastNeighborShape(TestContext ctx) internal static void EngineNote(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane("player_bhawk"); var stats = PlaneStats.Load(ctx.ZrdrPath, "player_bhawk"); // Everything below is quoted in PERCENT OF THE LEVEL BASELINE, so the shipped curve has to @@ -2534,18 +2606,34 @@ internal static void EngineVoiceDuckSuite(TestContext ctx) var soundGroups = SoundDefs.LoadGroups(ctx.ZrdrPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); float limit = stats.VoiceoverVolumeLimiter; - ctx.Check(Mathf.IsEqualApprox(limit, 0.4f), - $"player.zrd authors voiceover_volume_limiter={limit:0.000}, over the compiled 0.5"); + // The limit is the record's own, and one unlike the compiled 0.5, so a reader that missed + // the key cannot pass. The install pins the shipped 0.4 too. + float? authoredLimit = SuiteConstants.PlayerGlobal(ctx.ZrdrPath, "voiceover_volume_limiter"); + ctx.Check(authoredLimit is { } authored && !Mathf.IsEqualApprox(authored, 0.5f) && Mathf.IsEqualApprox(limit, authored), + $"player.zrd authors voiceover_volume_limiter={authoredLimit?.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture) ?? "absent"}, over the compiled 0.5, read as {limit:0.000}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(limit, 0.4f), + $"player.zrd authors voiceover_volume_limiter={limit:0.000}, over the compiled 0.5"); + } using var archive = new SoundArchive(ctx.SoundsPath); AudioStreamWav? Stream(string name) => soundDefs.TryGetValue(name, out var d) ? archive.Find(d.WavName, d.Looped) : null; // A mission's own VO waits 45 s for the channel and a combat bark 0.5 s (docs/formats/sounds.md). + // A combat line is a bark by its QUEUE wait. The install's own are the snd_id lines, taken + // first there and pinned, so the battery keeps the line it always ducked under. string? missionLine = DuckLine(soundDefs, Stream, d => d.QueueSeconds >= 40f, 3.0); - string? combatLine = DuckLine(soundDefs, Stream, + string? idLine = DuckLine(soundDefs, Stream, d => d.QueueSeconds < 1f && d.Name.StartsWith("snd_id", System.StringComparison.OrdinalIgnoreCase), 0.8); + string? combatLine = idLine ?? DuckLine(soundDefs, Stream, d => d.QueueSeconds < 1f, 0.8); ctx.Check(missionLine != null && combatLine != null, - $"a mission line of 3 s or more ({missionLine}) and a combat line of 0.8 s or more ({combatLine})"); + $"a mission line of 3 s or more ({missionLine}) and a bark of 0.8 s or more ({combatLine})"); + if (!ctx.SyntheticData) + { + ctx.Check(missionLine != null && idLine != null, + $"a mission line of 3 s or more ({missionLine}) and a combat line of 0.8 s or more ({idLine})"); + } if (missionLine == null || combatLine == null) return; ctx.Note($"lines: mission {missionLine} {Stream(missionLine)!.GetLength():0.00} s, combat {combatLine} {Stream(combatLine)!.GetLength():0.00} s"); @@ -3187,6 +3275,11 @@ void FireRocket(Transform3D muzzle, int shooterId, int steps = 30) $"concentrated fire on the nose bearing alone downs the plane rounds={oneZone}/{oneZoneBudget}"); ctx.Note($"one-bearing kill took {oneZone} rounds of {gun.Id} (redirect + whole-pool overflow)"); + // The sponge below is one shipped airframe against one shipped rocket, so only the + // install runs it. + if (ctx.SyntheticData) + return; + // --- the user-reported sponge, decode-confirmed fix: a Fury dies to a few HE // rockets (wep_06 BOOM, 40 armor / 60 health), fired head-on from one bearing. // Each detonation reaches the plane through the blast pass at its nearest box. @@ -3385,14 +3478,23 @@ bool Pristine(FlightController rig) => rig.Damage!.Parts.Values.All( // the speeds anyone actually flies. Decode: docs/org/shakes.md. private static void CheckRattleGate(TestContext ctx, PlaneStats stats) { - ctx.Check(Mathf.IsEqualApprox(stats.RattleSpeedGate, 1f), - $"the shipped rattle gate is {stats.RattleSpeedGate:0.###}x fd_speed (want 1)"); - ctx.Check(stats.RattleSound == "snd_planeshake", - $"…on the definition the rattle block names, {stats.RattleSound}"); - - float below = EngineAudioCurves.Rattle(stats, 0.999f); - float at = EngineAudioCurves.Rattle(stats, 1f); - float over = EngineAudioCurves.Rattle(stats, 1.2f); + float gate = stats.RattleSpeedGate; + float? authored = SuiteConstants.PlayerGlobal(ctx.ZrdrPath, "speed_range", "rattle"); + ctx.Check(authored is { } a && Mathf.IsEqualApprox(gate, a), + $"the rattle gate is the rattle block's own speed_range floor, {gate:0.###}x fd_speed (authored {authored?.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) ?? "absent"})"); + ctx.Check(stats.RattleSound is { } rattle && SoundDefs.Load(ctx.ZrdrPath).ContainsKey(rattle), + $"…on a definition the sound library holds, {stats.RattleSound}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(gate, 1f), + $"the shipped rattle gate is {gate:0.###}x fd_speed (want 1)"); + ctx.Check(stats.RattleSound == "snd_planeshake", + $"…on the definition the rattle block names, {stats.RattleSound}"); + } + + float below = EngineAudioCurves.Rattle(stats, gate * 0.999f); + float at = EngineAudioCurves.Rattle(stats, gate); + float over = EngineAudioCurves.Rattle(stats, gate * 1.2f); ctx.Check(below == 0f, $"a hair under the gate the rattle is silent: {below:0.0000}"); ctx.Check(Mathf.IsEqualApprox(at, EngineAudioCurves.RattleLevel()), $"…and reaches its full level the moment speed meets it: {at:0.0000}"); diff --git a/CSVM/src/Testing/CoopDropoffSuites.cs b/CSVM/src/Testing/CoopDropoffSuites.cs index ec9754d27..c23493fa8 100644 --- a/CSVM/src/Testing/CoopDropoffSuites.cs +++ b/CSVM/src/Testing/CoopDropoffSuites.cs @@ -58,6 +58,7 @@ internal static class CoopDropoffSuites internal static void CampaignCoopDropoff(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"aircraft archive"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), MissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {MissionSeq}"); diff --git a/CSVM/src/Testing/CoopEpisodeOwnerSuites.cs b/CSVM/src/Testing/CoopEpisodeOwnerSuites.cs index fdf23b4aa..468371670 100644 --- a/CSVM/src/Testing/CoopEpisodeOwnerSuites.cs +++ b/CSVM/src/Testing/CoopEpisodeOwnerSuites.cs @@ -45,6 +45,7 @@ internal static class CoopEpisodeOwnerSuites internal static void CampaignCoopEpisodeOwner(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); diff --git a/CSVM/src/Testing/CraterSuites.cs b/CSVM/src/Testing/CraterSuites.cs index 97ad0b14a..c26a42f16 100644 --- a/CSVM/src/Testing/CraterSuites.cs +++ b/CSVM/src/Testing/CraterSuites.cs @@ -72,7 +72,7 @@ internal static void ScorchDecals(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, Chapter); ctx.RequireData(texturesPath, $"{Chapter} textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_06", out var he)) + if (OrdnanceSuites.PickFireballRocket(ctx, weapons) is not { } he) { ctx.Check(false, $"wep_06 resolves"); return; diff --git a/CSVM/src/Testing/CutsceneFastForwardSuites.cs b/CSVM/src/Testing/CutsceneFastForwardSuites.cs index 83a0ae062..81c98d84c 100644 --- a/CSVM/src/Testing/CutsceneFastForwardSuites.cs +++ b/CSVM/src/Testing/CutsceneFastForwardSuites.cs @@ -64,6 +64,7 @@ private enum Hold internal static void HeldFastForward(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = CutsceneSkipSuites.MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/CutsceneOwnershipSuites.cs b/CSVM/src/Testing/CutsceneOwnershipSuites.cs index acac53c9c..1da23b889 100644 --- a/CSVM/src/Testing/CutsceneOwnershipSuites.cs +++ b/CSVM/src/Testing/CutsceneOwnershipSuites.cs @@ -37,6 +37,7 @@ internal static class CutsceneOwnershipSuites internal static void CutsceneOwnership(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionIn(CampaignSequence.Load(ctx.ZrdrPath), Chapter, Folder) ?? throw new SuiteSkippedException($"cm_sequence carries no {Chapter}/{Folder}"); diff --git a/CSVM/src/Testing/CutsceneSkipSuites.cs b/CSVM/src/Testing/CutsceneSkipSuites.cs index 28c8c9868..fb47df8c7 100644 --- a/CSVM/src/Testing/CutsceneSkipSuites.cs +++ b/CSVM/src/Testing/CutsceneSkipSuites.cs @@ -59,6 +59,7 @@ internal static class CutsceneSkipSuites internal static void CutsceneSkip(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); diff --git a/CSVM/src/Testing/DamageSuites.cs b/CSVM/src/Testing/DamageSuites.cs index 7a888688f..9567700a2 100644 --- a/CSVM/src/Testing/DamageSuites.cs +++ b/CSVM/src/Testing/DamageSuites.cs @@ -77,7 +77,7 @@ internal static void DamageHd(TestContext ctx) "a second panel's tear takes its own pooled gimmeflakes copy and leaves the first burst flying at its site (BL-288)")] internal static void DamageTemplatePool(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { var stage = new Node3D { Name = "DamagePoolStage" }; var pdp5 = PoolAnchorNode("pdp5", new Vector3(-10, 0, 0)); @@ -90,8 +90,8 @@ internal static void DamageTemplatePool(TestContext ctx) var pool = new Node3D { Name = $"pool{slot}" }; pool.SetMeta(AnimRuntime.PoolSlotMeta, slot); stage.AddChild(pool); - int built = Session.World.WorldEffectsFactory.BuildEffectStage(world.Gamez, - world.Session.Builder.Scene, pool, new[] { "planeflakes" }); + int built = Session.World.WorldEffectsFactory.BuildEffectStage(source.Gamez, + source.Scene, pool, new[] { "planeflakes" }); ctx.Check(built == 1, $"slot {slot} staged its planeflakes copy"); foreach (var child in pool.GetChildren()) { @@ -115,7 +115,7 @@ internal static void DamageTemplatePool(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(new[] { "pdpanel4", "pdpanel5" })); + runtime.Bind(stage, source.Program.Subset(new[] { "pdpanel4", "pdpanel5" })); runtime.Play("pdpanel5", stage, applyReset: false); for (int i = 0; i < 6; i++) { @@ -163,7 +163,7 @@ internal static void DamageTemplatePool(TestContext ctx) + "takes its own copy through the rebuilt maps")] internal static void DamageTemplateFreedAnchor(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { var stage = new Node3D { Name = "FreedAnchorStage" }; var pdp5 = PoolAnchorNode("pdp5", new Vector3(-10, 0, 0)); @@ -176,8 +176,8 @@ internal static void DamageTemplateFreedAnchor(TestContext ctx) var pool = new Node3D { Name = $"pool{slot}" }; pool.SetMeta(AnimRuntime.PoolSlotMeta, slot); stage.AddChild(pool); - Session.World.WorldEffectsFactory.BuildEffectStage(world.Gamez, - world.Session.Builder.Scene, pool, new[] { "planeflakes" }); + Session.World.WorldEffectsFactory.BuildEffectStage(source.Gamez, + source.Scene, pool, new[] { "planeflakes" }); foreach (var child in pool.GetChildren()) { if (child is Node3D copy) @@ -199,7 +199,7 @@ internal static void DamageTemplateFreedAnchor(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(new[] { "pdpanel4", "pdpanel5" })); + runtime.Bind(stage, source.Program.Subset(new[] { "pdpanel4", "pdpanel5" })); runtime.Play("pdpanel5", stage, applyReset: false); for (int i = 0; i < 6; i++) { @@ -446,9 +446,22 @@ internal static void CockpitPanelStaging(TestContext ctx) internal static void DamageStageSlots(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane("player_fury"); var stats = PlaneStats.LoadForAi(ctx.ZrdrPath, "player_fury"); - ctx.Check(stats.VehicleInjureAnims.Count == 7, - $"fury's AI ladder carries {stats.VehicleInjureAnims.Count} entries (want 7)"); + const string Remote = "random_remote_damage", Smoke = "pfsmoketrail"; + var ladder = stats.VehicleInjureAnims; + int remote = ladder.Count(e => e.Anim == Remote); + int smoke = ladder.Count(e => e.Anim == Smoke); + // A repair to half lifts the hull back over every entry under it and none at or above. + int remoteHeld = ladder.Count(e => e.Anim == Remote && e.Frac >= 0.5f); + int smokeHeld = ladder.Count(e => e.Anim == Smoke && e.Frac >= 0.5f); + ctx.Check(remote >= 2 && smoke >= 1 && remoteHeld < remote && ladder.All(e => e.Frac > 0.2f), + $"fury's AI ladder repeats {Remote} ({remote} entries, {remoteHeld} at or above half) beside {smoke} {Smoke}, every threshold above the walk's last step"); + if (!ctx.SyntheticData) + { + ctx.Check(ladder.Count == 7 && remote == 6 && smoke == 1 && remoteHeld == 4 && smokeHeld == 0, + $"fury's AI ladder carries {ladder.Count} entries (want 7: six {Remote}, four of them at or above half, and one {Smoke} under it)"); + } var root = new Node3D { Name = "fury_root" }; ctx.Host.AddChild(root); @@ -461,20 +474,20 @@ internal static void DamageStageSlots(TestContext ctx) // one step per band, so each threshold is crossed on its own foreach (float frac in new[] { 0.99f, 0.9f, 0.7f, 0.55f, 0.47f, 0.42f, 0.3f, 0.2f }) visuals.OnHullDamage(frac); - int repeats = visuals.StagedEntryCount("random_remote_damage"); - ctx.Check(repeats == 6, - $"the whole ladder walked down: {repeats} random_remote_damage entries staged (want 6)"); - ctx.Check(visuals.StagedEntryCount("pfsmoketrail") == 1, - $"…and the one pfsmoketrail entry at 0.40 staged with them"); + int repeats = visuals.StagedEntryCount(Remote); + ctx.Check(repeats == remote, + $"the whole ladder walked down: {repeats} {Remote} entries staged (want {remote})"); + ctx.Check(visuals.StagedEntryCount(Smoke) == smoke, + $"…and the {smoke} {Smoke} entry staged with them"); - // repaired to half: the three entries under 0.5 retract, the four at or above hold + // repaired to half: the entries under 0.5 retract, those at or above hold visuals.OnHullDamage(0.5f); - ctx.Check(visuals.StagedEntryCount("random_remote_damage") == 4 - && visuals.StagedEntryCount("pfsmoketrail") == 0, - $"repaired to 50%: {visuals.StagedEntryCount("random_remote_damage")} random_remote_damage and {visuals.StagedEntryCount("pfsmoketrail")} pfsmoketrail entries still staged (want 4 and 0)"); + ctx.Check(visuals.StagedEntryCount(Remote) == remoteHeld + && visuals.StagedEntryCount(Smoke) == smokeHeld, + $"repaired to 50%: {visuals.StagedEntryCount(Remote)} {Remote} and {visuals.StagedEntryCount(Smoke)} {Smoke} entries still staged (want {remoteHeld} and {smokeHeld})"); visuals.OnHullDamage(0.2f); - ctx.Check(visuals.StagedEntryCount("random_remote_damage") == 6 - && visuals.StagedEntryCount("pfsmoketrail") == 1, + ctx.Check(visuals.StagedEntryCount(Remote) == remote + && visuals.StagedEntryCount(Smoke) == smoke, $"…and every retracted entry fires again on the next descent"); } finally diff --git a/CSVM/src/Testing/DestroyChoreographySuites.cs b/CSVM/src/Testing/DestroyChoreographySuites.cs index 58631ac3f..8effa8bb4 100644 --- a/CSVM/src/Testing/DestroyChoreographySuites.cs +++ b/CSVM/src/Testing/DestroyChoreographySuites.cs @@ -49,7 +49,7 @@ internal static void CallbackEvents(TestContext ctx) "a template root CALLED REPEATEDLY from one anchor takes a pooled copy per authored call, not one for the anchor: pdpanel7's four gimmeflakes calls at pdp7 hold four copies, and a second tear reclaims those four rather than wrapping the pool (D21)")] internal static void RepeatCallSlots(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { var stage = new Node3D { Name = "RepeatCallStage" }; var pdp7 = DamageSuites.PoolAnchorNode("pdp7", new Vector3(-10, 0, 0)); @@ -60,8 +60,8 @@ internal static void RepeatCallSlots(TestContext ctx) var pool = new Node3D { Name = $"pool{slot}" }; pool.SetMeta(AnimRuntime.PoolSlotMeta, slot); stage.AddChild(pool); - Session.World.WorldEffectsFactory.BuildEffectStage(world.Gamez, - world.Session.Builder.Scene, pool, new[] { "planeflakes" }); + Session.World.WorldEffectsFactory.BuildEffectStage(source.Gamez, + source.Scene, pool, new[] { "planeflakes" }); foreach (var child in pool.GetChildren()) { if (child is Node3D copy) @@ -85,7 +85,7 @@ internal static void RepeatCallSlots(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(new[] { "pdpanel7" })); + runtime.Bind(stage, source.Program.Subset(new[] { "pdpanel7" })); // The authored sites: one call at pdp7 + 2 m, three at pdp7 - 2 m, the last three // staggered 0.2 / 0.3 / 0.4 s apart. Debris flies off after placement, so the set // is collected AS each call lands rather than sampled at the end. @@ -270,9 +270,9 @@ internal static void StopSequenceStops(TestContext ctx) "the PLAYER_1ST_PERSON condition follows the pilot's selected view mode: the bullethole def's else branch runs in Chase and is skipped in Cockpit and Nose (A1)")] internal static void PlayerFirstPersonCondition(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { - var program = world.Session.Program.Subset("bullet1"); + var program = source.Program.Subset("bullet1"); var defs = program.ByAnimName("bullet1"); ctx.Check(defs.Count > 0, $"the program carries the first-person-gated bullet1 def defs={defs.Count}"); if (defs.Count == 0) @@ -460,7 +460,7 @@ internal static void DebrisShading(TestContext ctx) "a destructible whose own Initial sequence authors the healthy/destroyed swap directly (a start-state script's shape, never DamageAt) leaves the HP pool destroyed too, so a later hit does not replay the death choreography (BL-513, BL-521)")] internal static void StartStateSwapSyncsThePool(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimWorld(ctx, ctx.Chapter, world => { var runtime = world.Runtime; DestructibleRegistry.Instance? subject = null; @@ -677,7 +677,7 @@ string Snap() => string.Join(" ", pieces.Select(p => "a persist-log state lands on the pool and the destroyed pose with no instance started, a carried partial HP lands at its stage, and a later hit on the carried kill is a no-op")] internal static void CarriedStateIsSilent(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimWorld(ctx, ctx.Chapter, world => { var runtime = world.Runtime; var subjects = new List(); diff --git a/CSVM/src/Testing/DisplaySettingsSuites.cs b/CSVM/src/Testing/DisplaySettingsSuites.cs index f4c6bd7a1..d7f060d2a 100644 --- a/CSVM/src/Testing/DisplaySettingsSuites.cs +++ b/CSVM/src/Testing/DisplaySettingsSuites.cs @@ -59,6 +59,7 @@ private static readonly (string Property, object Sample, bool Display)[] SavedFi ("RenderScale", "200", true), ("AntiAliasing", DisplayWords.AntiAliasingFsr2, true), ("ShadowQuality", ShadowQualitySetting.Low, true), + ("WaterQuality", WaterQualitySetting.Flat, true), ("AudioMaster", 0, false), ("AudioMusic", 100, false), ("AudioEffects", 50, false), diff --git a/CSVM/src/Testing/DropoffChutemanSuites.cs b/CSVM/src/Testing/DropoffChutemanSuites.cs index 6f679ef4c..f9cf97f04 100644 --- a/CSVM/src/Testing/DropoffChutemanSuites.cs +++ b/CSVM/src/Testing/DropoffChutemanSuites.cs @@ -49,6 +49,7 @@ internal static class DropoffChutemanSuites internal static void DropoffChutemanStage(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), MissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {MissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/DropoffPlacementSuites.cs b/CSVM/src/Testing/DropoffPlacementSuites.cs index e50f11ab5..8ae901b34 100644 --- a/CSVM/src/Testing/DropoffPlacementSuites.cs +++ b/CSVM/src/Testing/DropoffPlacementSuites.cs @@ -80,6 +80,7 @@ internal static class DropoffPlacementSuites internal static void DropoffPlacement(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), MissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {MissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); @@ -122,6 +123,7 @@ internal static void DropoffPlacement(TestContext ctx) internal static void CutsceneHandoffUnposed(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), UnposedMissionSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {UnposedMissionSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/EffectStageSuiteHelper.cs b/CSVM/src/Testing/EffectStageSuiteHelper.cs index 3c8f0ae19..79f2adb32 100644 --- a/CSVM/src/Testing/EffectStageSuiteHelper.cs +++ b/CSVM/src/Testing/EffectStageSuiteHelper.cs @@ -2,20 +2,105 @@ using System.Collections.Generic; using System.Linq; using CSVM.Effects; +using CSVM.Extraction; using CSVM.Mech3; using CSVM.Mech3.Anim; using CSVM.Session.World; +using CSVM.Tooling; using Godot; namespace CSVM.Testing; +/// What an effect or destructible suite stages from: an anim program, the gamez holding +/// its template roots, and their scene builder. +internal sealed record AnimSource(AnimProgram Program, GameZ Gamez, SceneBuilder Scene) +{ + internal static AnimSource Of(TestWorld world) => + new(world.Session.Program, world.Gamez, world.Session.Builder.Scene); +} + +/// A world a destructible suite runs on: its root, the runtime bound over it, and the +/// program and scene builder it was built from. +internal sealed record AnimWorld(Node3D Root, AnimRuntime Runtime, AnimProgram Program, SceneBuilder Scene); + internal static class EffectStageSuiteHelper { + /// The run's chapter world on an extraction. Under --synthetic-data, the + /// invented probe_effects gamez and archive plus the zrdr reader definitions. ⚠ Keep the + /// extraction branch the chapter world: the real install's suites read its shipped program. + internal static void WithAnimSource(TestContext ctx, Action body) => + WithAnimSource(ctx, ctx.Chapter, body); + + /// + internal static void WithAnimSource(TestContext ctx, string chapter, Action body) + { + if (!ctx.SyntheticData) + { + ctx.WithWorld(chapter, collision: false, world => body(AnimSource.Of(world))); + return; + } + + string gamezPath = SyntheticEffects.GamezUnder(ctx.DataRoot); + ctx.RequireData(gamezPath, $"synthetic effect templates"); + var gamez = GameZ.Load(gamezPath); + var defs = new List( + AnimArchive.Load(SyntheticEffects.AnimUnder(ctx.DataRoot), SyntheticEffects.Folder)?.Defs + ?? new List()); + defs.AddRange(AnimDefs.LoadArchive(ctx.ZrdrPath)); + using var textures = new TextureArchive(SessionPaths.ChapterTextures(ctx.DataRoot, SyntheticTextures.Chapter)); + body(new AnimSource(AnimProgram.FromDefinitions(defs), + gamez, new SceneBuilder(gamez, textures, fullbright: true, cullBackfaces: true))); + } + + /// The named chapter's world on an extraction. Under --synthetic-data, a private + /// world of the invented program's destructible roots, bound to that program. + internal static void WithAnimWorld(TestContext ctx, string chapter, Action body) + { + if (!ctx.SyntheticData) + { + ctx.WithWorld(chapter, collision: false, world => body(new AnimWorld(world.Session.Root, + world.Runtime, world.Session.Program, world.Session.Builder.Scene))); + return; + } + + WithAnimSource(ctx, source => + { + var root = new Node3D { Name = "SyntheticWorld" }; + foreach (var def in source.Program.Defs.Where(d => d.Destructible)) + { + if (source.Gamez.FindByName(def.Name) is { } node && source.Scene.BuildSubtree(node) is { } built) + { + root.AddChild(built); + } + } + + var runtime = new AnimRuntime { ManualAdvance = true }; + ctx.Host.AddChild(root); + ctx.Host.AddChild(runtime); + try + { + runtime.Bind(root, source.Program); + body(new AnimWorld(root, runtime, source.Program, source.Scene)); + } + finally + { + runtime.Free(); + root.Free(); + } + }); + } + /// stages the roots in that many pool copies, the shape the /// real world-effects stage is built in (WorldEffectsFactory, sized by /// effect_pools.json): a suite that asks whether overlapping calls keep their own sites /// needs the pool the session ships, not one copy. internal static void WithEffectStage(TestContext ctx, TestWorld world, string animName, + IEnumerable rootNames, Action body, + IEmitterFactory? factory = null, int slots = 1) => + WithEffectStage(ctx, AnimSource.Of(world), animName, rootNames, body, factory, slots); + + /// + internal static void WithEffectStage(TestContext ctx, AnimSource source, string animName, IEnumerable rootNames, Action body, IEmitterFactory? factory = null, int slots = 1) { @@ -28,7 +113,7 @@ internal static void WithEffectStage(TestContext ctx, TestWorld world, string an var pool = new Node3D { Name = $"pool{slot}" }; pool.SetMeta(AnimRuntime.PoolSlotMeta, slot); stage.AddChild(pool); - built += WorldEffectsFactory.BuildEffectStage(world.Gamez, world.Session.Builder.Scene, pool, rootNames); + built += WorldEffectsFactory.BuildEffectStage(source.Gamez, source.Scene, pool, rootNames); foreach (var child in pool.GetChildren()) if (child is Node3D root) root.Visible = false; @@ -51,7 +136,7 @@ internal static void WithEffectStage(TestContext ctx, TestWorld world, string an ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(animName)); + runtime.Bind(stage, source.Program.Subset(animName)); body(stage, runtime, ctx.Camera.GlobalPosition); } finally diff --git a/CSVM/src/Testing/GraphicsSwitchSuites.cs b/CSVM/src/Testing/GraphicsSwitchSuites.cs index a637892f5..a135bc687 100644 --- a/CSVM/src/Testing/GraphicsSwitchSuites.cs +++ b/CSVM/src/Testing/GraphicsSwitchSuites.cs @@ -15,9 +15,9 @@ namespace CSVM.Testing; -/// The live graphics-mode switch and the live View Distance, driven through the one -/// sequence the launcher runs (, -/// ) on whole flight sessions. A world switched away and +/// The live graphics-mode switch, View Distance and Water Quality on whole flight sessions. +/// Each runs through the one sequence the launcher runs (, +/// , ). A world switched away and /// back must read as a fresh session in that mode. That covers the layers only one mode builds, the /// clutter's cells and ranges, and the shader text on every material. It covers the Environment's /// passes and the sun's shadow too. The pixels are the montage's, not this suite's. @@ -27,6 +27,36 @@ internal static class GraphicsSwitchSuites // on a commit, so a reading taken before one would show the pool the switch left. private const int Steps = 3; + // The chapter these suites build the wave ocean on. Effects.Ocean.Covers lists every chapter it covers. + private const string OceanChapter = "C1B"; + + // A sea chapter this suite opens at flat water quality, so no ocean builds over it. Its sheet still + // carries the hide under Enhanced, so a switch a closed session left on would hole its whole sea. + private const string FlatWaterChapter = "C2B"; + + // How far the lifted ocean may stand below a ramp: the bilinear lift's rounding, not a gap. + private const float RampShortfall = 0.05f; + + // The call in the base sheet's fragment-stage discard. The include line alone never matches it. + private const string HideCall = "csky_ocean_hides_sea("; + + // Where the hide and its mask globals are declared. + private const string OceanIncludePath = "res://shaders/csky_ocean.gdshaderinc"; + + // The retext probe's key, red under Original and green under Enhanced. The Original block is the + // larger, so a stale compile also fails the Enhanced material's uniform buffer, as the world shaders did. + private const string OriginalProbe = "shader_type spatial;\nrender_mode unshaded, cull_disabled;\n// graphics-retext-compiles\n" + + "uniform vec4 probe_a = vec4(0.0);\nuniform vec4 probe_b = vec4(0.0);\nuniform vec4 probe_c = vec4(0.0);\n" + + "void fragment() {\n ALBEDO = vec3(1.0, 0.0, 0.0) + (probe_a.rgb + probe_b.rgb + probe_c.rgb) * 0.0;\n}\n"; + + private const string EnhancedProbe = "shader_type spatial;\nrender_mode unshaded, cull_disabled;\n// graphics-retext-compiles\n" + + "uniform vec4 probe_a = vec4(0.0);\n" + + "void fragment() {\n ALBEDO = vec3(0.0, 1.0, 0.0) + probe_a.rgb * 0.0;\n}\n"; + + // The chapters whose base sheet rises off sea level: C2's harbour to a quay, C5's water to the + // steinmann ship's hull. + private static readonly string[] RampChapters = { "C2", "C5" }; + // Every shader text a reading met, by its census key, for the mismatch artifact. private static readonly Dictionary TextByKey = new(StringComparer.Ordinal); @@ -122,7 +152,8 @@ internal static void LiveSwitchRoundTrip(TestContext ctx) [Suite("spyglass-sun", "under Enhanced the world sun sits on the layer every pane camera draws and the spyglass disc's " + "camera leaves out, and one shadowless copy on the disc's own layer, which no pane draws, " - + "matches the sun's bearing, colour and energy: in a two-pane flight with one disc per pane, " + + "matches the sun's bearing, colour and energy; each disc, and no pane, carries the flat-sea " + + "marker: in a two-pane flight with one disc per pane, " + "at every Shadow Quality level including Off, across a zone crossing the weather rig lights, " + "and after a live switch to Original (copy freed, sun back on layer 1, each disc the pane's " + "view less its own airframe) and back; a fresh Original flight builds no copy and moves no layer")] @@ -265,6 +296,344 @@ internal static void ViewDistanceCells(TestContext ctx) } } + [Suite("graphics-water-quality", + "the C1B wave ocean follows a live Water Quality change in an Enhanced flight: built into the " + + "world's tree at waves, out of the tree after a live move to flat (its leaving resets the " + + "switch the flat sheet's sea draws on), and built again by a move back to waves; a session " + + "built at flat builds none, and --no-ocean still builds none at waves, live apply included")] + internal static void WaterQualityOcean(TestContext ctx) + { + RequireData(ctx, OceanChapter); + bool wasEnhanced = GraphicsMode.Enhanced; + string launched = WaterQualitySetting.Word; + try + { + WaterQualitySetting.Resolve(WaterQualitySetting.Waves, null, null); + var live = Open(ctx, enhanced: true, chapter: OceanChapter); + try + { + if (!live.Built) + { + ctx.Check(false, $"the Enhanced C1B session builds"); + return; + } + + string built = OceanState(live); + ctx.Check(live.Session.OceanBuilt && built == "1 in tree", + $"at waves the session builds the ocean into its tree ({built})"); + ApplyWater(live, WaterQualitySetting.Flat); + string flat = OceanState(live); + ctx.Check(!live.Session.OceanBuilt && flat == "0 in tree", + $"a live move to flat drops it and the sheet draws its sea again ({flat})"); + ApplyWater(live, WaterQualitySetting.Waves); + string again = OceanState(live); + ctx.Check(live.Session.OceanBuilt && again == "1 in tree", + $"and a move back to waves builds it again ({again})"); + } + finally + { + live.Close(); + } + + WaterQualitySetting.Resolve(WaterQualitySetting.Flat, null, null); + var flatBuild = Open(ctx, enhanced: true, chapter: OceanChapter); + try + { + string none = OceanState(flatBuild); + ctx.Check(flatBuild.Built && !flatBuild.Session.OceanBuilt && none == "0 in tree", + $"a session built at flat builds no ocean (built={flatBuild.Built}, {none})"); + } + finally + { + flatBuild.Close(); + } + + WaterQualitySetting.Resolve(WaterQualitySetting.Waves, null, null); + var door = Open(ctx, enhanced: true, chapter: OceanChapter, extra: new[] { "--no-ocean" }); + try + { + EnhancedLook.ApplyWaterQuality(door.Session); + string skipped = OceanState(door); + ctx.Check(door.Built && !door.Session.OceanBuilt && skipped == "0 in tree", + $"and --no-ocean still builds none at waves, through a live apply as well (built={door.Built}, {skipped})"); + } + finally + { + door.Close(); + } + } + finally + { + WaterQualitySetting.Resolve(launched, null, null); + Restore(wasEnhanced); + } + } + + [Suite("graphics-ocean-switch", + "the C1B wave ocean follows a live graphics switch at water quality waves: an Enhanced build " + + "stands one ocean and every sea-level base sheet material carries the hide, which reads " + + "that ocean's mask and steps aside only at the coverage the ocean discards below, and neither " + + "the grid nor the hide acts for a spyglass disc's camera; " + + "switched to Original the ocean leaves the tree, the mask globals return to their no-sea " + + "defaults and the sheet carries a fresh Original " + + "build's text, with no hide; back to Enhanced exactly one ocean stands and the sheet " + + "carries a fresh Enhanced build's text; an Original build switched to Enhanced builds the " + + "ocean; a closed session leaves no ocean in the tree, and a following Enhanced C2B session " + + "at flat water quality builds none while its sheet still carries the hide")] + internal static void OceanFollowsSwitch(TestContext ctx) + { + RequireData(ctx, OceanChapter); + RequireData(ctx, FlatWaterChapter); + bool wasEnhanced = GraphicsMode.Enhanced; + string launched = WaterQualitySetting.Word; + var report = new StringBuilder(); + try + { + ViewDistance.Set(null); + WaterQualitySetting.Resolve(WaterQualitySetting.Waves, null, null); + SeaReading freshOriginal, originalToEnhanced; + Effects.Ocean? builtLive; + var original = Open(ctx, enhanced: false, chapter: OceanChapter); + try + { + if (!original.Built) + { + ctx.Check(false, $"the Original C1B session builds"); + return; + } + freshOriginal = Sea(original); + Switch(original, true); + originalToEnhanced = Sea(original); + builtLive = FirstOcean(ctx.Host); + } + finally + { + original.Close(); + } + ctx.Check(freshOriginal.Oceans == 0 && freshOriginal.Materials > 0 && freshOriginal.Drawn > 0 && freshOriginal.Hidden == 0, + $"an Original C1B build stands no ocean and its base sheet carries no hide ({freshOriginal})"); + ctx.Check(originalToEnhanced.Oceans == 1 && originalToEnhanced.Hidden == originalToEnhanced.Materials, + $"switched to Enhanced it builds the ocean and hides the sheet ({originalToEnhanced})"); + Left(ctx, builtLive, "the Original build switched to Enhanced"); + report.AppendLine($"fresh original\n{freshOriginal.Print()}\noriginal switched to enhanced\n{originalToEnhanced.Print()}"); + + var enhanced = Open(ctx, enhanced: true, chapter: OceanChapter); + Effects.Ocean? builtFresh; + try + { + if (!enhanced.Built) + { + ctx.Check(false, $"the Enhanced C1B session builds"); + return; + } + var freshEnhanced = Sea(enhanced); + builtFresh = FirstOcean(ctx.Host); + ctx.Check(freshEnhanced.Oceans == 1 && enhanced.Session.OceanBuilt && freshEnhanced.Materials > 0 + && freshEnhanced.Drawn > 0 && freshEnhanced.Hidden == freshEnhanced.Materials, + $"an Enhanced C1B build stands one ocean and every base sheet material hides its sea-level fragments ({freshEnhanced})"); + HideReadsTheMask(ctx, enhanced, builtFresh); + ctx.Check(originalToEnhanced.Census == freshEnhanced.Census, + $"the Original build switched to Enhanced carries a fresh Enhanced build's base sheet text"); + ctx.Check(freshEnhanced.Census != freshOriginal.Census, + $"ABLE-TO-FAIL CONTROL: the two modes' fresh base sheet texts differ"); + + Switch(enhanced, false); + var switchedOriginal = Sea(enhanced); + ctx.Check(switchedOriginal.Oceans == 0 && !enhanced.Session.OceanBuilt && OceansUnder(ctx.Host) == 0 + && builtFresh is { } dropped && (!GodotObject.IsInstanceValid(dropped) || !dropped.IsInsideTree()), + $"switched to Original the ocean leaves the tree ({switchedOriginal}, {OceansUnder(ctx.Host)} under the host)"); + ctx.Check(switchedOriginal.Hidden == 0 && switchedOriginal.Census == freshOriginal.Census, + $"and the base sheet carries a fresh Original build's text, with no hide ({switchedOriginal.Hidden} hiding)"); + ctx.Check(Effects.OceanMask.Live == null, + $"and the hide's mask globals hold their no-sea defaults again"); + + Switch(enhanced, true); + var roundTrip = Sea(enhanced); + var rebuilt = FirstOcean(ctx.Host); + ctx.Check(roundTrip.Oceans == 1 && OceansUnder(ctx.Host) == 1 && rebuilt != null && !ReferenceEquals(rebuilt, builtFresh), + $"back to Enhanced the ocean is built again, exactly once ({roundTrip}, {OceansUnder(ctx.Host)} under the host)"); + ctx.Check(roundTrip.Hidden == roundTrip.Materials && roundTrip.Census == freshEnhanced.Census, + $"and the base sheet carries a fresh Enhanced build's text, hidden again ({roundTrip.Hidden} of {roundTrip.Materials} hiding)"); + ctx.Check(Effects.OceanMask.Live != null, + $"and the rebuilt ocean hands its mask to the hide again"); + report.AppendLine($"fresh enhanced\n{freshEnhanced.Print()}\nswitched original\n{switchedOriginal.Print()}\nround trip\n{roundTrip.Print()}"); + builtFresh = rebuilt; + } + finally + { + enhanced.Close(); + } + Left(ctx, builtFresh, "the round-tripped Enhanced session"); + ctx.Check(Effects.OceanMask.Live == null, + $"and with the ocean gone the hide's mask globals hold their no-sea defaults"); + + WaterQualitySetting.Resolve(WaterQualitySetting.Flat, null, null); + var flat = Open(ctx, enhanced: true, chapter: FlatWaterChapter); + try + { + var sea = flat.Built ? Sea(flat) : null; + ctx.Check(flat.Built && !flat.Session.OceanBuilt + && sea is { Oceans: 0 } && OceansUnder(ctx.Host) == 0, + $"a following Enhanced {FlatWaterChapter} session at flat water builds no ocean (built={flat.Built}, {sea?.ToString() ?? "no reading"}, {OceansUnder(ctx.Host)} under the host)"); + ctx.Check(sea is { Materials: > 0, Drawn: > 0 } && sea.Hidden == sea.Materials, + $"and its drawn sheet carries the hide, so a switch left on would hole its sea ({sea?.ToString() ?? "no reading"})"); + report.AppendLine($"flat water {FlatWaterChapter}\n{sea?.Print() ?? "no reading"}"); + } + finally + { + flat.Close(); + } + ctx.WriteArtifact("test-graphics-ocean-switch.txt", report.ToString()); + } + finally + { + WaterQualitySetting.Resolve(launched, null, null); + Restore(wasEnhanced); + } + } + + [Suite("ocean-lab", + "the ocean lab stands in an Enhanced --freecam C1B session and nowhere in a --fly one: opened, " + + "a length edit rewrites the standing ocean's shader text and a height edit only its uniform, " + + "with one ocean standing throughout; an ocean rebuilt by a switch to Original and back draws " + + "the edited sea, a reset draws the defaults, and a revert to the shipped sea writes the text the " + + "session opened with")] + internal static void OceanLabEdits(TestContext ctx) + { + RequireData(ctx, OceanChapter); + bool wasEnhanced = GraphicsMode.Enhanced; + string launched = WaterQualitySetting.Word; + var shipped = Effects.OceanSeas.Load().For(OceanChapter); + + // Edits that differ from the shipped sea, whatever the lab saved there. + float length = shipped.Length == 1.5f ? 1.6f : 1.5f; + float height = shipped.Height == 0.8f ? 0.9f : 0.8f; + try + { + WaterQualitySetting.Resolve(WaterQualitySetting.Waves, null, null); + var freecam = Open(ctx, enhanced: true, chapter: OceanChapter, extra: "--freecam"); + try + { + var labs = Labs(freecam.Session); + var ocean = FirstOcean(freecam.Session); + if (!freecam.Built || labs.Count != 1 || ocean == null) + { + ctx.Check(false, $"the Enhanced --freecam C1B session builds one ocean lab over one ocean (built={freecam.Built}, {labs.Count} lab(s), ocean {(ocean != null ? "standing" : "missing")})"); + return; + } + var lab = labs[0]; + ctx.Check(!lab.IsOpen && lab.Edited == shipped && lab.Saved == shipped && ocean.Sea == shipped, + $"the lab starts closed on the shipped {OceanChapter} sea {shipped.Describe()} (lab {lab.Edited.Describe()}, ocean {ocean.Sea.Describe()})"); + string opened = ocean.ShaderText; + lab.Toggle(); + ctx.Check(lab.IsOpen, $"Toggle opens the panel"); + + lab.Set("length", length); + Step(freecam); + string lengthened = ocean.ShaderText; + ctx.Check(lengthened != opened && ocean.Sea.Length == length && OceansUnder(freecam.Session) == 1 + && ReferenceEquals(FirstOcean(freecam.Session), ocean), + $"a length edit rewrites the standing ocean's shader text, and the same one ocean stands ({OceansUnder(freecam.Session)} in the tree, sea {ocean.Sea.Describe()})"); + + lab.Set("height", height); + Step(freecam); + float waveScale = Grid(ocean)?.GetShaderParameter("wave_scale").AsSingle() ?? -1f; + ctx.Check(ocean.ShaderText == lengthened && Mathf.IsEqualApprox(waveScale, height), + $"a height edit sets wave_scale alone and compiles nothing (wave_scale {waveScale.ToString("0.###", CultureInfo.InvariantCulture)})"); + + Switch(freecam, false); + bool droppedInOriginal = OceansUnder(freecam.Session) == 0; + Switch(freecam, true); + var rebuilt = FirstOcean(freecam.Session); + ctx.Check(droppedInOriginal && rebuilt != null && !ReferenceEquals(rebuilt, ocean) + && rebuilt.Sea == lab.Edited && rebuilt.ShaderText == lengthened, + $"an ocean rebuilt by a switch to Original and back draws the edited sea (dropped={droppedInOriginal}, rebuilt sea {rebuilt?.Sea.Describe() ?? "none"})"); + + lab.ResetToDefaults(); + Step(freecam); + ctx.Check(rebuilt?.Sea == Effects.SeaState.Default && lab.Edited == Effects.SeaState.Default, + $"a reset draws the defaults (sea {rebuilt?.Sea.Describe() ?? "none"})"); + + lab.RevertToSaved(); + Step(freecam); + ctx.Check(rebuilt?.ShaderText == opened && rebuilt.Sea == shipped, + $"a revert to the shipped sea writes the text the session opened with (sea {rebuilt?.Sea.Describe() ?? "none"})"); + } + finally + { + freecam.Close(); + } + + var fly = Open(ctx, enhanced: true, chapter: OceanChapter, extra: "--fly"); + try + { + ctx.Check(fly.Built && fly.Session.OceanBuilt && Labs(fly.Session).Count == 0, + $"a --fly C1B session stands its ocean and builds no ocean lab (built={fly.Built}, ocean={fly.Session.OceanBuilt}, {Labs(fly.Session).Count} lab(s))"); + } + finally + { + fly.Close(); + } + } + finally + { + WaterQualitySetting.Resolve(launched, null, null); + Restore(wasEnhanced); + } + } + + [Suite("ocean-harbour-ramp", + "an Enhanced flight at water quality waves over C2's harbour ramps and C5's water rising to the " + + "steinmann ship takes each base-sheet ramp off sea level as open sea, which the sheet's hide " + + "clears whole, so no flat patch stands over the waves; the calm ocean rises with each ramp, never " + + "standing below it, so no gap opens under the quay or hull it meets; every base-sheet triangle over " + + "the sea is cleared wherever it lies, so none draws or casts a shadow onto the ocean; and a sheet " + + "raised off a sea texel no ramp covers still draws")] + internal static void OceanHarbourRamp(TestContext ctx) + { + foreach (string chapter in RampChapters) + RequireData(ctx, chapter); + bool wasEnhanced = GraphicsMode.Enhanced; + string launched = WaterQualitySetting.Word; + try + { + ViewDistance.Set(null); + WaterQualitySetting.Resolve(WaterQualitySetting.Waves, null, null); + foreach (string chapter in RampChapters) + { + var rig = Open(ctx, enhanced: true, chapter: chapter); + try + { + var mask = Effects.OceanMask.Live; + if (!rig.Built || !rig.Session.OceanBuilt || mask == null || rig.Session.WorldScene == null) + { + ctx.Check(false, $"the Enhanced {chapter} session builds its ocean (built={rig.Built}, ocean={rig.Session.OceanBuilt})"); + continue; + } + var sheet = SheetOverSea(rig, mask); + ctx.Check(sheet.Ramps > 0 && mask.RampTriangles >= sheet.Ramps && sheet.RampMissed == 0, + $"{chapter}: the ramps are open sea the hide clears whole ({sheet.Ramps} ramp triangle(s) drawn, {mask.RampTriangles} in the mask, {sheet.RampMissed} sample(s) on them left standing{sheet.Examples})"); + ctx.Check(sheet.Ramps > 0 && sheet.RampShortfall < RampShortfall, + $"{chapter}: the calm ocean rises with each ramp, so no gap opens under what it meets (the ocean stands at most {sheet.RampShortfall.ToString("0.000", CultureInfo.InvariantCulture)} m below a ramp)"); + ctx.Check(sheet.Sampled > 0 && sheet.Missed == 0, + $"{chapter}: every base-sheet triangle over the sea is cleared, so none draws or casts onto the ocean ({sheet.Sampled} sample(s) over the sea, {sheet.Missed} left standing, {sheet.Casting} in shadow-casting instances{sheet.Examples})"); + var flat = sheet.FlatSea; + ctx.Check(flat is { } at && mask.ClearsSheet(at) && !mask.ClearsSheet(at + new Vector3(0f, 1f, 0f)), + $"ABLE-TO-FAIL CONTROL: {chapter}: off a ramp the hide clears the sheet at sea level and keeps it 1 m up (at {flat?.ToString() ?? "no flat sea sample"})"); + } + finally + { + rig.Close(); + } + } + } + finally + { + WaterQualitySetting.Resolve(launched, null, null); + Restore(wasEnhanced); + } + } + [Suite("graphics-shader-twins", "after the load warm-up has compiled the other mode's twin of every cache shader, a live " + "switch to Original and back makes no new shader and rewrites no shader's text, so Godot " @@ -316,6 +685,71 @@ internal static void ShaderTwins(TestContext ctx) } } + [Suite("graphics-retext-compiles", + "a key whose Original shader nobody wears any more, rewritten in place by a first switch to " + + "Enhanced before an Enhanced frame has drawn, stays pinned on a material of its own; after " + + "one TAA frame builds Godot's advanced variants, a fresh material on the key draws the " + + "Enhanced text with no engine error, and the Original text on a shader of its own draws " + + "red. Only a process that has drawn no TAA frame before can show the draw fail; the pin " + + "check fails in any")] + internal static void RetextCompiles(TestContext ctx) + { + bool wasEnhanced = GraphicsMode.Enhanced; + bool wasDrawn = Mech3.ShaderTwins.EnhancedDrawn; + var host = new Node { Name = "retext_probe" }; + ctx.Host.AddChild(host); + ShaderMaterial? fresh = null; + try + { + GraphicsMode.Set(false); + Mech3.ShaderTwins.Regenerate(); + Mech3.ShaderTwins.EnhancedDrawn = false; + RenderingServer.ForceSync(); + int? errorsBefore = TestHarness.EngineErrorsSoFar(); + + // ⚠ Draw nothing before the rewrite. A draw leaves Godot compiling pipelines on worker + // threads, and a rewrite then has a worker rebuild the shader and fail its free_rid calls. + var key = Mech3.ShaderTwins.Make(() => GraphicsMode.Enhanced ? EnhancedProbe : OriginalProbe, + "retext-probe", "retext-probe"); + var worn = Mech3.ShaderTwins.Follow(new ShaderMaterial(), key); + var original = worn.Shader; + worn.GetRid(); + // The key keeps no wearer, so the rewrite queues no material update of Godot's own. + worn.Dispose(); + + GraphicsMode.Set(true); + Mech3.ShaderTwins.Regenerate(); + bool rewritten = ReferenceEquals(key.Current, original); + bool pinned = Mech3.ShaderTwins.IsPinned(original); + AdvancedFrame(host); + + var (view, quad) = ProbeView(host); + fresh = Mech3.ShaderTwins.Follow(new ShaderMaterial(), key); + quad.MaterialOverride = fresh; + var drawnEnhanced = ProbePixel(view); + quad.MaterialOverride = new ShaderMaterial { Shader = new Shader { Code = OriginalProbe } }; + var drawnOriginal = ProbePixel(view); + RenderingServer.ForceSync(); + int? errorsAfter = TestHarness.EngineErrorsSoFar(); + + ctx.Check(rewritten, $"the first switch to Enhanced rewrites the unworn Original shader in place"); + ctx.Check(pinned, $"and pins the rewritten shader on a material of its own"); + ctx.Check(drawnOriginal.R > 0.7f && drawnOriginal.G < 0.3f, + $"ABLE-TO-FAIL CONTROL: the Original text draws red ({drawnOriginal})"); + ctx.Check(drawnEnhanced.G > 0.7f && drawnEnhanced.R < 0.3f, + $"after a TAA frame a fresh material on the key draws the Enhanced text, green ({drawnEnhanced})"); + ctx.Check(errorsBefore != null && errorsAfter == errorsBefore, + $"with no engine error ({(errorsAfter ?? 0) - (errorsBefore ?? 0)} new line(s), log {(errorsBefore != null ? "read" : "missing")})"); + } + finally + { + host.Free(); + fresh?.Dispose(); + Mech3.ShaderTwins.EnhancedDrawn = wasDrawn; + Restore(wasEnhanced); + } + } + [Suite("world-merge", "an Enhanced flight session draws its static world's opaque surfaces merged by shared node frame " + "and material, and every material's placed-world vertices once: the same count as on the faithful " @@ -418,6 +852,52 @@ internal static void SwitchCoverOrder(TestContext ctx) } } + // A small pane of its own world, no TAA, whose quad fills the camera's view. + private static (SubViewport View, MeshInstance3D Quad) ProbeView(Node host) + { + var view = new SubViewport + { + Size = new Vector2I(64, 64), + OwnWorld3D = true, + World3D = new World3D(), + RenderTargetUpdateMode = SubViewport.UpdateMode.Always, + RenderTargetClearMode = SubViewport.ClearMode.Always, + }; + view.AddChild(new Camera3D { Current = true }); + var quad = new MeshInstance3D { Mesh = new QuadMesh { Size = new Vector2(4f, 4f) }, Position = new Vector3(0f, 0f, -1f) }; + view.AddChild(quad); + host.AddChild(view); + quad.ForceUpdateTransform(); + return (view, quad); + } + + // The pane's centre after a forced draw. Twice, so a material set this frame has its update and + // its pipeline before the read. + private static Color ProbePixel(SubViewport view) + { + RenderingServer.ForceDraw(); + RenderingServer.ForceDraw(); + var image = view.GetTexture()?.GetImage(); + return image == null || image.IsEmpty() ? new Color(0f, 0f, 0f, 0f) : image.GetPixel(32, 32); + } + + // One drawn TAA frame, which is what has Godot build its advanced variants for every shader alive. + private static void AdvancedFrame(Node host) + { + var view = new SubViewport + { + Size = new Vector2I(256, 256), + OwnWorld3D = true, + World3D = new World3D(), + UseTaa = true, + RenderTargetUpdateMode = SubViewport.UpdateMode.Always, + }; + view.AddChild(new Camera3D { Current = true }); + host.AddChild(view); + RenderingServer.ForceDraw(); + view.Free(); + } + // The load warm-up's hidden TAA frame: raised once by an Original process that has drawn no // Enhanced frame, and never by an Enhanced one. Freed before it draws, so this shard builds no // advanced variants. @@ -648,6 +1128,8 @@ private static void DiscMasks(TestContext ctx, Rig rig, bool enhanced, string la uint mask = pane.Camera.CullMask; ctx.Check((mask & UI.Boards.SplitScreen.SpyglassSunLayer) == 0 && (mask & UI.Boards.SplitScreen.SunLayer) != 0, $"{label}: pane {pane.Index + 1} draws the sun's layer and not the copy's (0x{mask:X5})"); + ctx.Check((mask & UI.Boards.SplitScreen.FlatSeaLayer) == 0, + $"{label}: pane {pane.Index + 1} does not carry the flat-sea marker, so it draws the wave ocean (0x{mask:X5})"); } foreach (var view in views) { @@ -658,8 +1140,9 @@ private static void DiscMasks(TestContext ctx, Rig rig, bool enhanced, string la disc == SpyglassView.DiscMask(p.Camera.CullMask, UI.Boards.SplitScreen.OwnAirframeLayer(p.Index), enhanced)); bool sun = (disc & UI.Boards.SplitScreen.SunLayer) != 0; bool copy = (disc & UI.Boards.SplitScreen.SpyglassSunLayer) != 0; - ctx.Check(fromPane && sun != enhanced && copy == enhanced, - $"{label}: a disc draws its pane's view less its own airframe, with {(enhanced ? "the copy in place of the sun" : "the sun itself")} (0x{disc:X5}: sun {sun}, copy {copy})"); + bool flat = (disc & UI.Boards.SplitScreen.FlatSeaLayer) != 0; + ctx.Check(fromPane && sun != enhanced && copy == enhanced && flat == enhanced, + $"{label}: a disc draws its pane's view less its own airframe, with {(enhanced ? "the copy in place of the sun and the flat-sea marker" : "the sun itself and no flat-sea marker")} (0x{disc:X5}: sun {sun}, copy {copy}, flat sea {flat})"); } } @@ -723,12 +1206,202 @@ private static int Count(Node root) private static string Light(DirectionalLight3D light) => string.Create(CultureInfo.InvariantCulture, $"layers 0x{light.Layers:X5} casts {light.ShadowEnabled} energy {light.LightEnergy:0.###} colour {light.LightColor.ToHtml(false)} specular {light.LightSpecular:0.###} angular {light.LightAngularDistance:0.##} beam {-light.GlobalBasis.Z}"); - private static void RequireData(TestContext ctx) + private static void RequireData(TestContext ctx, string? chapter = null) { + chapter ??= ctx.Chapter; ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); - ctx.RequireData(SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} textures"); - ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); + ctx.RequireData(SessionPaths.ChapterTextures(ctx.DataRoot, chapter), $"{chapter} textures"); + ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, chapter), $"{chapter} gamez"); + } + + // A Water Quality apply as the Options accept makes it, then the steps a reading waits for. + private static void ApplyWater(Rig rig, string word) + { + WaterQualitySetting.Resolve(word, null, null); + EnhancedLook.ApplyWaterQuality(rig.Session); + Step(rig); + } + + // The ocean nodes standing in the session's tree. Entering and leaving the tree sets the sheet's + // switch. The server's getter for that errors outside the editor, so the tree is read instead. + private static string OceanState(Rig rig) => + string.Create(CultureInfo.InvariantCulture, $"{OceansUnder(rig.Session)} in tree"); + + // The ocean nodes standing in the tree under root. Read under the host, it also counts one + // parented outside the session. + private static int OceansUnder(Node root) + { + int count = 0; + Walk(root, node => + { + if (node is Effects.Ocean { } ocean && ocean.IsInsideTree()) + count++; + }); + return count; + } + + private static Effects.Ocean? FirstOcean(Node root) + { + Effects.Ocean? found = null; + Walk(root, node => found ??= node as Effects.Ocean); + return found; + } + + private static List Labs(Node root) + { + var labs = new List(); + Walk(root, node => + { + if (node is UI.Labs.OceanLab lab) + labs.Add(lab); + }); + return labs; + } + + // The material every grid of the ocean shares. + private static ShaderMaterial? Grid(Effects.Ocean ocean) => + ocean.GetNodeOrNull("OceanGrid")?.Mesh?.SurfaceGetMaterial(0) as ShaderMaterial; + + // A closed session's ocean has left the tree, which is what puts the sheet's switch back. + private static void Left(TestContext ctx, Effects.Ocean? ocean, string what) + { + bool gone = ocean == null || !GodotObject.IsInstanceValid(ocean) || !ocean.IsInsideTree(); + ctx.Check(ocean != null && gone && OceansUnder(ctx.Host) == 0, + $"closing {what} leaves no ocean in the tree (held ocean {(ocean == null ? "never built" : gone ? "gone" : "still in the tree")}, {OceansUnder(ctx.Host)} under the host)"); + } + + // The sheet's hide steps aside only where this ocean draws. Its mask is the one the hide reads, + // and the include gates on the coverage below which the ocean's own fragment discards. Neither + // the grid nor the hide acts for a spyglass disc's camera. + private static void HideReadsTheMask(TestContext ctx, Rig rig, Effects.Ocean? ocean) + { + string sheet = ""; + foreach (var (material, texture) in rig.Session.WorldScene?.TexturedMaterials ?? Array.Empty<(ShaderMaterial, string)>()) + { + if (Mech3.SceneBuilder.IsOceanBaseTexture(texture) && material.Shader?.Code is { } code + && code.Contains(HideCall, StringComparison.Ordinal)) + { + sheet = code; + break; + } + } + string threshold = Effects.OceanMask.SeaThreshold.ToString("0.0#", CultureInfo.InvariantCulture); + string include = ResourceLoader.Load(OceanIncludePath)?.Code ?? ""; + string grid = ocean?.GetNodeOrNull("OceanGrid")?.Mesh?.SurfaceGetMaterial(0) is ShaderMaterial { Shader: { } shader } + ? shader.Code : ""; + ctx.Check(ocean != null && Effects.OceanMask.Live != null, + $"the standing ocean hands its mask to the sheet's hide"); + ctx.Check(include.Contains($"csky_ocean_sea(world.xz) < {threshold}", StringComparison.Ordinal) + && grid.Contains($"m.r < {threshold}", StringComparison.Ordinal), + $"the sheet's hide steps aside at the coverage the ocean discards below, {threshold} (include {(include.Length > 0 ? "read" : "missing")}, grid shader {(grid.Length > 0 ? "read" : "missing")})"); + ctx.Check(include.Contains($"abs(zone - {Effects.OceanMask.SeamZone}.0)", StringComparison.Ordinal), + $"and leaves a zone seam texel, which no grid draws, to the sheet"); + string hide = Effects.OceanMask.HideHeight.ToString("0.0#", CultureInfo.InvariantCulture); + string top = Effects.OceanMask.RampTop.ToString("0.0#", CultureInfo.InvariantCulture); + ctx.Check(include.Contains($"world.y <= -{hide} || world.y >= {top})", StringComparison.Ordinal) + && include.Contains($"world.y >= {hide} && !csky_ocean_ramp(", StringComparison.Ordinal) + && include.Contains($"r * 255.0 - {Effects.OceanMaskRaster.RampSea}.0", StringComparison.Ordinal), + $"and clears the sheet within {hide} m of sea level, and a ramp's up to {top} m where its filter reads the ramp byte {Effects.OceanMaskRaster.RampSea}"); + ctx.Check(grid.Contains($"if ({Mech3.SceneBuilder.FlatSeaEye}) {{", StringComparison.Ordinal) + && sheet.Length > 0 && sheet.Contains($"!{Mech3.SceneBuilder.FlatSeaEye} && {HideCall}", StringComparison.Ordinal), + $"a spyglass disc's camera collapses the grid before its wave sum and keeps the sheet whole ({Mech3.SceneBuilder.FlatSeaEye}; sheet {(sheet.Length > 0 ? "read" : "missing")})"); + } + + // Every drawn base-sheet triangle read at its centroid and near each corner. A sample over a sea + // texel must be one the hide clears. A ramp rises off sea level to under OceanMask.RampTop, and + // its shortfall is how far the lifted ocean stands below it. + private static SheetReading SheetOverSea(Rig rig, Effects.OceanMask mask) + { + var scene = rig.Session.WorldScene!; + var names = new Dictionary(); + foreach (var (material, texture) in scene.TexturedMaterials) + names[material] = texture; + int sampled = 0, missed = 0, casting = 0, ramps = 0, rampMissed = 0; + float gap = 0f; + Vector3? flat = null; + var examples = new StringBuilder(); + Walk(rig.Session, node => + { + if (node is not MeshInstance3D { Mesh: ArrayMesh mesh } mi || !mi.IsVisibleInTree() || UnderOcean(mi)) + return; + var xf = mi.GlobalTransform; + for (int s = 0; s < mesh.GetSurfaceCount(); s++) + { + var material = mi.GetSurfaceOverrideMaterial(s) ?? mesh.SurfaceGetMaterial(s); + if (material == null || !names.TryGetValue(material, out var texture) || !Mech3.SceneBuilder.IsOceanBaseTexture(texture)) + continue; + var arrays = scene.SurfaceArrays(mesh, s); + var v = arrays[(int)Mesh.ArrayType.Vertex].AsVector3Array(); + var ix = arrays[(int)Mesh.ArrayType.Index].VariantType == Variant.Type.Nil + ? Enumerable.Range(0, v.Length).ToArray() : arrays[(int)Mesh.ArrayType.Index].AsInt32Array(); + for (int t = 0; t + 2 < ix.Length; t += 3) + { + var a = xf * v[ix[t]]; + var b = xf * v[ix[t + 1]]; + var c = xf * v[ix[t + 2]]; + float minY = Math.Min(a.Y, Math.Min(b.Y, c.Y)), maxY = Math.Max(a.Y, Math.Max(b.Y, c.Y)); + bool ramp = minY > -0.5f && minY < 0.5f && maxY >= Effects.OceanMask.HideHeight && maxY < Effects.OceanMask.RampTop; + ramps += ramp && (b - a).Cross(c - a).Length() > 1e-3f ? 1 : 0; + var centre = (a + b + c) / 3f; + var samples = new[] { centre, a.Lerp(centre, 0.15f), b.Lerp(centre, 0.15f), c.Lerp(centre, 0.15f) }; + if (ramp) + samples = samples.Concat(new[] { a.Lerp(centre, 0.02f), b.Lerp(centre, 0.02f), c.Lerp(centre, 0.02f) }).ToArray(); + foreach (var p in samples) + { + if (ramp) + gap = Math.Max(gap, p.Y - mask.LiftAt(p)); + if (!mask.IsSea(p)) + continue; + sampled++; + if (!ramp && maxY < 0.5f) + flat ??= p; + if (mask.ClearsSheet(p)) + continue; + missed++; + rampMissed += ramp ? 1 : 0; + casting += mi.CastShadow != GeometryInstance3D.ShadowCastingSetting.Off ? 1 : 0; + if (missed <= 4) + examples.Append(string.Create(CultureInfo.InvariantCulture, $"; {mi.GetParent()?.Name}/{mi.Name} at ({p.X:0.0},{p.Y:0.00},{p.Z:0.0})")); + } + } + } + }); + return new SheetReading(sampled, missed, casting, ramps, rampMissed, gap, flat, examples.ToString()); + } + + private static bool UnderOcean(Node node) + { + for (Node? n = node; n != null; n = n.GetParent()) + { + if (n is Effects.Ocean) + return true; + } + return false; + } + + // The base sheet's materials as the world builder named them, by texture and shader text. Also + // how many carry the hide and are drawn. + private static SeaReading Sea(Rig rig) + { + var sheet = new HashSet(); + var census = new SortedDictionary(StringComparer.Ordinal); + int hidden = 0; + foreach (var (material, texture) in rig.Session.WorldScene?.TexturedMaterials ?? Array.Empty<(ShaderMaterial, string)>()) + { + if (!Mech3.SceneBuilder.IsOceanBaseTexture(texture) || !sheet.Add(material)) + continue; + string code = material.Shader?.Code ?? ""; + hidden += code.Contains(HideCall, StringComparison.Ordinal) ? 1 : 0; + Count(census, texture.ToLowerInvariant() + ":" + code.GetHashCode().ToString("x8", CultureInfo.InvariantCulture)); + } + int drawn = 0; + Walk(rig.Session, node => + { + if (node is GeometryInstance3D geometry && geometry.IsVisibleInTree()) + drawn += Materials(geometry).Count(sheet.Contains); + }); + return new SeaReading(OceansUnder(rig.Session), sheet.Count, hidden, drawn, Print(census)); } // The switch as the launcher makes it, then the steps a reading waits for. @@ -746,13 +1419,13 @@ private static void Step(Rig rig) // One flight session in its own pane, lit as the launcher lights one. The sun and the // Environment are the launcher's, dressed by EnhancedLook when the session opens under Enhanced. - private static Rig Open(TestContext ctx, bool enhanced, int players = 1) + private static Rig Open(TestContext ctx, bool enhanced, int players = 1, string? chapter = null, params string[] extra) { GraphicsMode.Set(enhanced); Mech3.ShaderTwins.Regenerate(); EffectsLevel.RegisteredScaleSq = EnhancedLook.ClutterFadeScaleSq(); RenderingServer.GlobalShaderParameterSet(EffectsLevel.ShaderParam, EffectsLevel.RegisteredScaleSq); - var spec = SessionSpec.Parse(new[] { $"--chapter={ctx.Chapter}", $"--players={players}", "--mute", "--no-pads" }); + var spec = SessionSpec.Parse(new[] { $"--chapter={chapter ?? ctx.Chapter}", $"--players={players}", "--mute", "--no-pads" }.Concat(extra)); var pane = new SubViewport { Size = new Vector2I(640, 480), @@ -784,6 +1457,7 @@ private static Rig Open(TestContext ctx, bool enhanced, int players = 1) ctx.Host.AddChild(pane); var session = new GameSession(spec, new LauncherContext { + Decode = ctx.Decode, RepoRoot = ctx.RepoRoot, DataRoot = ctx.DataRoot, PlanesGamezPath = ctx.PlanesGamezPath, @@ -1050,6 +1724,16 @@ public override string ToString() } } + private sealed record SheetReading(int Sampled, int Missed, int Casting, int Ramps, int RampMissed, float RampShortfall, Vector3? FlatSea, string Examples); + + private sealed record SeaReading(int Oceans, int Materials, int Hidden, int Drawn, string Census) + { + public override string ToString() => string.Create(CultureInfo.InvariantCulture, + $"{Oceans} ocean(s), {Materials} base sheet material(s), {Hidden} hiding, {Drawn} drawn surface(s)"); + + public string Print() => $" {this}\n census {Census}"; + } + private sealed record CoverReading(string Order, int WorkFrame, bool HeldThroughStall, int Steps, bool Dropped, bool CoverInTree, bool HeldAfter, bool PausedAfter, int StepsAfter); diff --git a/CSVM/src/Testing/GroundShadowSuites.cs b/CSVM/src/Testing/GroundShadowSuites.cs index 6ed109397..a9c495753 100644 --- a/CSVM/src/Testing/GroundShadowSuites.cs +++ b/CSVM/src/Testing/GroundShadowSuites.cs @@ -81,6 +81,7 @@ internal static void GroundShadow(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(RotorPlane); var report = new StringBuilder(); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); diff --git a/CSVM/src/Testing/HudKillLineSuites.cs b/CSVM/src/Testing/HudKillLineSuites.cs index 3d51c4d75..8ecc7f70e 100644 --- a/CSVM/src/Testing/HudKillLineSuites.cs +++ b/CSVM/src/Testing/HudKillLineSuites.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using CSVM.Extraction; using CSVM.Flight.Ai; using CSVM.Flight.Airframe; @@ -51,9 +52,14 @@ internal static void HudKillLine(TestContext ctx) string shotDown = strings.Get(HudMessages.ShotDownKey); string wingman = strings.Get(HudMessages.WingmanKey); string destroyed = strings.Get(HudMessages.DestroyedKey); - ctx.Check(shotDown == "was shot down" && wingman == "Wingman was shot down" - && destroyed == "was destroyed", - $"the three decoded rows read out of the string table: '{shotDown}' / '{wingman}' / '{destroyed}'"); + ctx.Check(Rows(strings, HudMessages.ShotDownKey, HudMessages.WingmanKey, HudMessages.DestroyedKey), + $"the three decoded rows read out of the string table as three wordings: '{shotDown}' / '{wingman}' / '{destroyed}'"); + if (!ctx.SyntheticData) + { + ctx.Check(shotDown == "was shot down" && wingman == "Wingman was shot down" + && destroyed == "was destroyed", + $"the three decoded rows read out of the string table: '{shotDown}' / '{wingman}' / '{destroyed}'"); + } Wording(ctx, strings, shotDown, wingman, destroyed); Pilot(ctx, strings, shotDown, wingman); @@ -109,13 +115,19 @@ internal static void HudKillLine(TestContext ctx) } }; + // The install flies the Medusa Kestrel the line was reported on. Any other tree flies its + // own default airframe under its first AI flavour, and the line names it by its title. var at = new Vector3(0f, 500f, 0f); - ai = roster.SpawnAi(new AiSpawn("player_kestrel", at, at + Vector3.Forward, - AiPilot.HoldingCourse(at, at + Vector3.Forward), Scheme: null, - Team: InstantActionRuntime.EnemyTeam, AiDef: "medkestrel")); + ai = roster.SpawnAi(new AiSpawn(ctx.SyntheticData ? ctx.PlaneName : "player_kestrel", at, + at + Vector3.Forward, AiPilot.HoldingCourse(at, at + Vector3.Forward), Scheme: null, + Team: InstantActionRuntime.EnemyTeam, AiDef: ctx.SyntheticData ? null : "medkestrel")); string name = PlaneRoster.PlaneDisplayName(ai.Stats!); - ctx.Check(name == "Medusa Kestrel", - $"the spawned aircraft carries the title the line names it by: '{name}'"); + ctx.Check(name.Length > 0, $"the spawned aircraft carries a title the line names it by: '{name}'"); + if (!ctx.SyntheticData) + { + ctx.Check(name == "Medusa Kestrel", + $"the spawned aircraft carries the title the line names it by: '{name}'"); + } ctx.Check(HudMessages.IsAeroplane(ai.Stats) && ai.Team == InstantActionRuntime.EnemyTeam, $"…as an aeroplane on an enemy team mode={ai.Stats?.VehicleMode ?? ""} team={ai.Team}"); @@ -135,7 +147,7 @@ internal static void HudKillLine(TestContext ctx) // The same real aircraft as the death that does word a line: a hull spent in the air. HudMessages.PostKill(stack, strings, ai, AimAssist.PlayerTeam, victimIsViewer: false, viewerName: "Nathan Zachary"); - ctx.Check(stack.LineAt(0) == $"Medusa Kestrel {shotDown}", + ctx.Check(stack.LineAt(0) == $"{name} {shotDown}", $"its death posts the decoded line: '{stack.LineAt(0) ?? ""}'"); ctx.Check(stack.SideAt(0) == HudMessages.Side.Enemy, $"…in the enemy colour arm side={stack.SideAt(0)}"); @@ -229,8 +241,13 @@ private static void Notices(TestContext ctx, HudMessages stack, Messages strings string crash = strings.Get(HudMessages.CrashKey); string expired = strings.Get(HudMessages.TimeExpiredKey); string lost = strings.Get(HudMessages.MissionLostKey); - ctx.Check(crash == "Fatal Crash!" && expired == "Time Expired" && lost == "Mission LOST!", - $"the three notice rows read out of the string table: '{crash}' / '{expired}' / '{lost}'"); + ctx.Check(Rows(strings, HudMessages.CrashKey, HudMessages.TimeExpiredKey, HudMessages.MissionLostKey), + $"the three notice rows read out of the string table as three wordings: '{crash}' / '{expired}' / '{lost}'"); + if (!ctx.SyntheticData) + { + ctx.Check(crash == "Fatal Crash!" && expired == "Time Expired" && lost == "Mission LOST!", + $"the three notice rows read out of the string table: '{crash}' / '{expired}' / '{lost}'"); + } HudMessages.PostCrash(stack, strings); ctx.Check(stack.LineAt(0) == crash && stack.SideAt(0) == HudMessages.Side.Friendly, @@ -245,15 +262,32 @@ private static void Notices(TestContext ctx, HudMessages stack, Messages strings $"…both in the stack's default colour side={stack.SideAt(0)}/{stack.SideAt(1)}"); stack.Clear(); + // The Dogfight lines are the table's two templates filled with the pilots' names; the + // install pins the shipped wordings too. + ctx.Check(Rows(strings, HudMessages.SelfDestroyedKey, HudMessages.DestroyedByKey), + $"the two Dogfight templates read out of the string table: '{strings.Get(HudMessages.SelfDestroyedKey)}' / '{strings.Get(HudMessages.DestroyedByKey)}'"); + string selfLoss = strings.Format(HudMessages.SelfDestroyedKey, "P2"); + string killedBy = strings.Format(HudMessages.DestroyedByKey, "P1"); HudMessages.PostMatchKill(stack, strings, HudMessages.MatchDeath.NoKiller, "P2", null); - ctx.Check(stack.LineAt(0) == "P2 Self-Destroyed" && stack.LineAt(1) == null, - $"a Dogfight death no killer owns still words itself, with no killer named: '{stack.LineAt(0) ?? ""}'"); + ctx.Check(stack.LineAt(0) == selfLoss && selfLoss.Contains("P2") && stack.LineAt(1) == null, + $"a Dogfight death no killer owns still words itself from its template, with no killer named: '{stack.LineAt(0) ?? ""}'"); + if (!ctx.SyntheticData) + { + ctx.Check(stack.LineAt(0) == "P2 Self-Destroyed" && stack.LineAt(1) == null, + $"a Dogfight death no killer owns still words itself, with no killer named: '{stack.LineAt(0) ?? ""}'"); + } stack.Clear(); HudMessages.PostMatchKill(stack, strings, HudMessages.MatchDeath.Killer, "P2", "P1"); - ctx.Check(stack.LineAt(0) == "P2" && stack.LineAt(1) == "Destroyed by P1" + ctx.Check(stack.LineAt(0) == "P2" && stack.LineAt(1) == killedBy && killedBy.Contains("P1") && stack.SideAt(0) == HudMessages.Side.Enemy && stack.SideAt(1) == HudMessages.Side.Enemy, - $"a Dogfight kill reads the victim over its killer, both in the enemy arm: '{stack.LineAt(0) ?? ""}' / '{stack.LineAt(1) ?? ""}' side={stack.SideAt(0)}/{stack.SideAt(1)}"); + $"a Dogfight kill reads the victim over its killer's template, both in the enemy arm: '{stack.LineAt(0) ?? ""}' / '{stack.LineAt(1) ?? ""}' side={stack.SideAt(0)}/{stack.SideAt(1)}"); + if (!ctx.SyntheticData) + { + ctx.Check(stack.LineAt(0) == "P2" && stack.LineAt(1) == "Destroyed by P1" + && stack.SideAt(0) == HudMessages.Side.Enemy && stack.SideAt(1) == HudMessages.Side.Enemy, + $"a Dogfight kill reads the victim over its killer, both in the enemy arm: '{stack.LineAt(0) ?? ""}' / '{stack.LineAt(1) ?? ""}' side={stack.SideAt(0)}/{stack.SideAt(1)}"); + } stack.Clear(); } @@ -331,4 +365,13 @@ private static void Stack(TestContext ctx, HudMessages stack) $"a spent slot 0 has nothing to push, so the next line enters alone"); stack.Clear(); } + + // Whether the table words every key, each differently. A key the table lacks reads back as + // itself, which is the miss this tells apart from a row. + private static bool Rows(Messages strings, params string[] keys) + { + var words = keys.Select(strings.Get).ToList(); + return words.Select((w, i) => w.Length > 0 && w != keys[i]).All(found => found) + && words.Distinct(System.StringComparer.Ordinal).Count() == words.Count; + } } diff --git a/CSVM/src/Testing/InstantActionSuites.cs b/CSVM/src/Testing/InstantActionSuites.cs index 3088aad47..026fc8fd4 100644 --- a/CSVM/src/Testing/InstantActionSuites.cs +++ b/CSVM/src/Testing/InstantActionSuites.cs @@ -48,6 +48,9 @@ internal static void InstantActionAce(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + // The spawns fly the configured airframes on an install and the tree's own plane on the + // synthetic tree, which carries none of them. The display-name table is code either way. + string Flown(string displayName) => ctx.SyntheticData ? ctx.PlaneName : InstantAction.PlaneNodeFor(displayName)!; // The display-name -> gamez-node table (docs/formats/instant-action.md's IDS_IA_PLANES // order): a real entry resolves, a typo/invention does not. @@ -154,7 +157,7 @@ internal static void InstantActionAce(TestContext ctx) // so Spawn's crash-runtime block (the only reader) is skipped. var spawner = new FlightRoster(FlightRosterPolicy.From(spec), liveries, null!, ctx.Host, inputs, new FlightWorldBindings { Projectiles = live, Gamez = planesGamez }, new HumanRosterBindings()); - string aceNode = InstantAction.PlaneNodeFor("Warhawk")!; + string aceNode = Flown("Warhawk"); var aceLivery = new PaintScheme { Pattern = "cccp", Color1 = PaintScheme.FromBytes(200, 10, 10) }; var pos = new Vector3(0f, 500f, 0f); var pilot = AiPilot.HoldingCourse(pos, pos + Vector3.Forward); @@ -169,7 +172,7 @@ internal static void InstantActionAce(TestContext ctx) // The wingman census: N aircraft on team 1, since humans and wingmen share the player's side, // flying the configured airframe. Spawned through the same FlightRoster.SpawnAi seam as the // ace above, on AimAssist.PlayerTeam instead of the enemy team. - string wingmanNode = InstantAction.PlaneNodeFor("Fury")!; + string wingmanNode = Flown("Fury"); for (int i = 0; i < 3; i++) { var wPos = new Vector3(500f + i * 10f, 500f, 0f); @@ -192,8 +195,11 @@ internal static void InstantActionAce(TestContext ctx) wingmen.Add(spawner.SpawnAi(new AiSpawn(wingmanNode, volPos, volPos + Vector3.Forward, volPilot, Scheme: null, Team: AimAssist.PlayerTeam))); var vol = volPilot.Machine; - ctx.Check(Mathf.IsEqualApprox(vol.AttackRange, 2000f) - && Mathf.IsEqualApprox(vol.ReturnRange, 1200f), + var gates = PlaneStats.Load(ctx.ZrdrPath, wingmanNode); + (float attack, float back) = ctx.SyntheticData ? (gates.AiAttackRange, gates.AiReturnRange) : (2000f, 1200f); + ctx.Check(Mathf.IsEqualApprox(vol.AttackRange, attack) + && Mathf.IsEqualApprox(vol.ReturnRange, back) + && !Mathf.IsEqualApprox(attack, InstantActionRuntime.ActorVolumeRadiusM), $"the spawner seeds the airframe's own gates first: attack={vol.AttackRange:0} return={vol.ReturnRange:0}"); InstantActionRuntime.ApplyActorVolumes(vol); ctx.Check(Mathf.IsEqualApprox(vol.ActivationRange, InstantActionRuntime.ActorVolumeRadiusM) @@ -218,7 +224,7 @@ internal static void InstantActionAce(TestContext ctx) int firstWave = iaWaves.Start(); ctx.Check(firstWave == 1, $"wave 1 is current at mission start: {firstWave}"); - string waveNode = InstantAction.PlaneNodeFor("Brigand")!; + string waveNode = Flown("Brigand"); var wave1Pos = new Vector3(0f, 500f, 0f); for (int i = 0; i < 2; i++) { diff --git a/CSVM/src/Testing/IntroAircraftSuites.cs b/CSVM/src/Testing/IntroAircraftSuites.cs index cec385918..f8a75400c 100644 --- a/CSVM/src/Testing/IntroAircraftSuites.cs +++ b/CSVM/src/Testing/IntroAircraftSuites.cs @@ -62,6 +62,7 @@ internal static class IntroAircraftSuites internal static void IntroAircraftStage(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), IntroSeq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {IntroSeq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/LandingApproachSuites.cs b/CSVM/src/Testing/LandingApproachSuites.cs index 7a3a77282..b389733ba 100644 --- a/CSVM/src/Testing/LandingApproachSuites.cs +++ b/CSVM/src/Testing/LandingApproachSuites.cs @@ -457,6 +457,7 @@ private static void DriveMission( MissionDrive drive) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {seq}"); string chapter = mission.ChapterFolder.ToUpperInvariant(); diff --git a/CSVM/src/Testing/LoadProgressSuites.cs b/CSVM/src/Testing/LoadProgressSuites.cs index 97e6d0b07..ae6760f6b 100644 --- a/CSVM/src/Testing/LoadProgressSuites.cs +++ b/CSVM/src/Testing/LoadProgressSuites.cs @@ -51,6 +51,8 @@ internal static class LoadProgressSuites internal static void LoadScreenMoves(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireData(System.IO.Path.Combine(ctx.DataRoot, "extracted", "rimage", "prog_red.png"), $"progress strip"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var report = new StringBuilder(); CheckFamily(ctx, report, campaign: false, sheet: null, stripWidth: ChalkStripWidth); diff --git a/CSVM/src/Testing/LoadSheetSuites.cs b/CSVM/src/Testing/LoadSheetSuites.cs index f267d075f..42d944192 100644 --- a/CSVM/src/Testing/LoadSheetSuites.cs +++ b/CSVM/src/Testing/LoadSheetSuites.cs @@ -46,6 +46,7 @@ internal static class LoadSheetSuites internal static void LoadSheetScreen(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); // Every campaign mission, not the run's own chapter: no world is built here, so the whole // sequence is affordable, and the filmed mission has to be in it whatever chapter is up. diff --git a/CSVM/src/Testing/MenuCampaignSuites.cs b/CSVM/src/Testing/MenuCampaignSuites.cs index 8954ffd15..1f072d1b7 100644 --- a/CSVM/src/Testing/MenuCampaignSuites.cs +++ b/CSVM/src/Testing/MenuCampaignSuites.cs @@ -75,6 +75,7 @@ internal static class MenuCampaignSuites internal static void MenuCampaignJourney(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); // Its Planes folder sits under the root below, so the root's own sweeps remove it. diff --git a/CSVM/src/Testing/MenuCoopDoorSuites.cs b/CSVM/src/Testing/MenuCoopDoorSuites.cs index ace4e3ff6..495659dbe 100644 --- a/CSVM/src/Testing/MenuCoopDoorSuites.cs +++ b/CSVM/src/Testing/MenuCoopDoorSuites.cs @@ -35,7 +35,6 @@ internal static class MenuCoopDoorSuites + "takes a net seat the guest's session hears, and leaving the campaign unmaps the port")] internal static void TheCoopDoor(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); // The first host wire serves the open-and-close control, the second the match itself. A // loopback end binds once, so every open needs its own. diff --git a/CSVM/src/Testing/MenuHangarSuites.cs b/CSVM/src/Testing/MenuHangarSuites.cs index 642a8f364..c85e9a8eb 100644 --- a/CSVM/src/Testing/MenuHangarSuites.cs +++ b/CSVM/src/Testing/MenuHangarSuites.cs @@ -41,7 +41,10 @@ internal static class MenuHangarSuites private static readonly MenuCommands Right = new() { MoveX = 1 }; private static readonly MenuCommands Left = new() { MoveX = -1 }; - [Suite("menu-hangar-journey", + // No check here depends on the install: a label is Built-in's own or is read back through the + // string table the screen drew from. The paint art, which only the install has, is + // menu-hangar-journey's. + [Suite("menu-hangar-journey-core", "Built-in's hangar journey pinned end to end: a real LaunchMenu opens the flow from the Mode " + "screen's Build Custom Plane row and from the Instant Action plane pick's trailing row, " + "walks plane selection, airframe (the first confirm picks and an edited build's swap raises " @@ -55,12 +58,11 @@ internal static class MenuHangarSuites + "draws the Purchase Now row refused with the hangar limit under it at the decoded slot cap " + "and live again one plane sold back, and drops an open build on a presentation switch; " + "every check is what the screens do today")] - internal static void MenuHangarJourney(TestContext ctx) + internal static void MenuHangarJourneyCore(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); - var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-hangar-journey"); + var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-hangar-journey-core"); ctx.Host.AddChild(menu); var store = menu.PlaneStore!; string scratch = ScratchName(); @@ -81,6 +83,30 @@ internal static void MenuHangarJourney(TestContext ctx) Delete(ctx, menu, store, scratch); } finally + { + ctx.Host.RemoveChild(menu); + menu.QueueFree(); + MenuSuiteHost.DropScratchPlanes(ctx, "menu-hangar-journey-core"); + } + } + + [Suite("menu-hangar-journey", + "Built-in's hangar art from the install: the paint screen a walked Devastator build reaches " + + "composes a preview of its paint, and the --menu=paint aid's nose decal row shows the " + + "decal's own tile beside the rows")] + internal static void MenuHangarJourney(TestContext ctx) + { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireData(Extraction.RofTree.Under(ctx.DataRoot, "ASSETS/GRAPHICS/PX_P_DECALS.TGA"), $"paint decal sheet"); + var exits = new List(); + var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); + var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-hangar-journey"); + ctx.Host.AddChild(menu); + try + { + PaintArt(ctx, menu); + } + finally { ctx.Host.RemoveChild(menu); menu.QueueFree(); @@ -321,7 +347,6 @@ private static void BuildScreens(TestContext ctx, LaunchMenu menu) menu.Drive(Right); ctx.Check(flow.Scratch.PaintPattern != pattern && HangarPaintTables.Default.Available(flow.Scratch.PaintPattern, flow.Scratch.Airframe), $"Right steps to the next pattern this airframe may wear ({pattern} -> {flow.Scratch.PaintPattern})"); - ctx.Check(flow.Page.Art != null, $"the paint screen composes a preview"); menu.Drive(Back); ctx.Check(flow.Screen == HangarScreen.Hardpoints, $"Back returns to hardpoints ({flow.Screen})"); menu.Drive(Accept); @@ -441,7 +466,33 @@ private static void Aids(TestContext ctx, LaunchMenu menu) menu.ShowMenu("paint"); ctx.Check(menu.Hangar?.Screen == HangarScreen.Paint && menu.Hangar.Scratch is { Airframe: 7, PaintPattern: 4, NoseDecal: 40 } && menu.ShownRow == HangarPaintPage.NoseDecalRow, $"--menu=paint opens the paint screen on a Fury in Fortune Hunters colours with a nose decal ({menu.Hangar?.Scratch.Airframe}, {menu.Hangar?.Scratch.PaintPattern}, {menu.Hangar?.Scratch.NoseDecal})"); - ctx.Check(menu.Hangar?.Page.RowArt(menu.ShownRow) != null, $"with the decal's tile beside the rows"); + } + + // Both pieces of paint art are cut from the install's icon layers and decal sheet. Each is asked + // of a state the core journey reaches without it. + private static void PaintArt(TestContext ctx, LaunchMenu menu) + { + // The journey's build: a Devastator walked page by page to paint, one pattern stepped on. + menu.ShowMenu("airframe"); + if (menu.Hangar is not { } flow) + { + ctx.Check(false, $"--menu=airframe opens the hangar flow ({menu.ShownScreen})"); + return; + } + + flow.FocusRow(HangarFeature.DefaultAirframe); + for (int guard = 0; flow.Screen != HangarScreen.Paint && guard < HangarFlow.Order.Length + 1; guard++) + { + menu.Drive(Accept); + } + + menu.Drive(Right); + ctx.Check(flow.Screen == HangarScreen.Paint && flow.Page.Art != null, + $"the paint screen composes a preview ({flow.Screen}, airframe {flow.Scratch.Airframe}, pattern {flow.Scratch.PaintPattern})"); + + menu.ShowMenu("paint"); + ctx.Check(menu.Hangar is { Screen: HangarScreen.Paint } paint && paint.Page.RowArt(menu.ShownRow) != null, + $"with the decal's tile beside the rows (--menu=paint, row {menu.ShownRow})"); } private static void CampaignDoor(TestContext ctx, LaunchMenu menu) diff --git a/CSVM/src/Testing/MenuHostSuites.cs b/CSVM/src/Testing/MenuHostSuites.cs index 0edb9af06..fe7b448c3 100644 --- a/CSVM/src/Testing/MenuHostSuites.cs +++ b/CSVM/src/Testing/MenuHostSuites.cs @@ -35,7 +35,6 @@ internal static class MenuHostSuites + "hover keeps the pad's row, and a click off the locked airframe is refused")] internal static void MenuHostPointer(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var seat = new ScriptedSeat(); var registry = new PresentationRegistry(); @@ -76,7 +75,6 @@ internal static void MenuHostPointer(TestContext ctx) + "and airframe cursors kept and the selection dropped")] internal static void MenuHostTracer(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var seat = new ScriptedSeat(); var registry = new PresentationRegistry(); diff --git a/CSVM/src/Testing/MenuInstantActionSuites.cs b/CSVM/src/Testing/MenuInstantActionSuites.cs index 7179c4154..d5c889960 100644 --- a/CSVM/src/Testing/MenuInstantActionSuites.cs +++ b/CSVM/src/Testing/MenuInstantActionSuites.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using CSVM.Extraction; using CSVM.Flight.Hangar; using CSVM.Flight.Weapons; using CSVM.Mech3; @@ -34,7 +35,10 @@ internal static class MenuInstantActionSuites private static readonly MenuCommands Left = new() { MoveX = -1 }; private static readonly MenuCommands Right = new() { MoveX = 1 }; - [Suite("menu-instant-action-journey", + // Without the install an environment's base def is InstantAction.Defaults(). The wizard's rows, + // presets and launch fields are the menu's own, so only the ace, which the base def carries, + // needs the install. That check is menu-instant-action-journey's. + [Suite("menu-instant-action-journey-core", "Built-in's Instant Action journey pinned end to end: a real LaunchMenu is driven Mode to " + "Environment, the Table of Contents applies a preset, Mission type steps the lives, a second " + "seat on Stunt Flying hides the stepper, which a race does not spend, and another type or a " @@ -44,12 +48,11 @@ internal static class MenuInstantActionSuites + "wingman loadout, the launch leaves as a LaunchExit carrying the built InstantActionDef, " + "the fields survive a return from flight, and the --menu= and --debug-* aids open their " + "states; every check is what the screens do today")] - internal static void MenuInstantActionJourney(TestContext ctx) + internal static void MenuInstantActionJourneyCore(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); - var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-instant-action-journey"); + var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-instant-action-journey-core"); ctx.Host.AddChild(menu); var launches = new Launches(exits); try @@ -73,6 +76,44 @@ internal static void MenuInstantActionJourney(TestContext ctx) } } + [Suite("menu-instant-action-journey", + "Built-in's Instant Action launch over the install: the Girl Trouble preset's squadron " + + "launched from Sky Haven through the wizard carries the environment's own ace, not the " + + "built-in default the def falls back to without the install")] + internal static void MenuInstantActionJourney(TestContext ctx) + { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(SessionPaths.MissionZrdr(ctx.DataRoot, "C4", "IA1"), "ia.json"); + var exits = new List(); + var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); + var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-instant-action-journey"); + ctx.Host.AddChild(menu); + var launches = new Launches(exits); + try + { + // The core journey's launch: the first preset applied, then Accept through Mission, + // Waves, Wingmen and both presses on the Aircraft screen. The first show builds the + // seats the preset writes its aircraft onto. + menu.ShowMenu(); + menu.DebugPreset(0); + menu.ShowMenu("environment"); + for (int press = 0; press < 6 && launches.Count == 0; press++) + { + menu.Drive(Accept); + } + + var def = launches.Count == 1 ? launches[0].InstantAction : null; + ctx.Check(launches.Count == 1 && launches[0].Chapter == "C4" && def != null + && def.AceName != "Marshall Bill Redmann" && def.AceName.Length > 0, + $"the ace is the environment's own, not the built-in default ({launches.Count} launches, {(launches.Count == 1 ? launches[0].Chapter : "-")}, {def?.AceName})"); + } + finally + { + ctx.Host.RemoveChild(menu); + menu.QueueFree(); + } + } + [Suite("menu-original-instant-action", "Original Instant Action through the presentation boundary over the install's decoded " + "layout: the top level's Instant Action row is live and a click opens the decoded screen " @@ -456,8 +497,6 @@ private static void Launch(TestContext ctx, LaunchMenu menu, Launches launches) $"the second as the preset had it ({def.Waves[1]})"); ctx.Check(def.Waves[2] == InstantAction.EmptyWave && def.Waves[3] == InstantAction.EmptyWave, $"the two unused slots as the empty wave"); - ctx.Check(def.AceName != "Marshall Bill Redmann" && def.AceName.Length > 0, - $"the ace is the environment's own, not the built-in default ({def.AceName})"); ctx.Check(menu.ShownScreen == "Plane" && menu.Visible, $"the menu keeps its state for the host to hide ({menu.ShownScreen})"); } diff --git a/CSVM/src/Testing/MenuJourneySuites.cs b/CSVM/src/Testing/MenuJourneySuites.cs index e1de944cf..dc105120c 100644 --- a/CSVM/src/Testing/MenuJourneySuites.cs +++ b/CSVM/src/Testing/MenuJourneySuites.cs @@ -28,6 +28,7 @@ internal static class MenuJourneySuites internal static void MenuFreeFlightJourney(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane("player_autogyro"); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-free-flight-journey"); diff --git a/CSVM/src/Testing/MenuLaunchReturnSuites.cs b/CSVM/src/Testing/MenuLaunchReturnSuites.cs index b89a395f5..d0c36bed2 100644 --- a/CSVM/src/Testing/MenuLaunchReturnSuites.cs +++ b/CSVM/src/Testing/MenuLaunchReturnSuites.cs @@ -53,7 +53,6 @@ internal static class MenuLaunchReturnSuites + "re-showing the same presentation without re-selecting")] internal static void MenuLaunchReturn(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -92,13 +91,12 @@ internal static void MenuLaunchReturn(TestContext ctx) [Suite("menu-backdrop", "the persistent environment's background over an Options restart and a launch, on the " + "launcher's own WorldEnvironment: a run that shows no menu leaves it on the sky, the " - + "menu's first show blacks it, Built-in's Options screen offers eighteen steppers, two doors " + + "menu's first show blacks it, Built-in's Options screen offers nineteen steppers, two doors " + "and no presentation row, the frame between the apply's exit and the restart carries no " + "presentation and stays black, the restart stands a fresh Built-in up over the same " + "black, and a launch puts the sky back with the material the rig built still on it")] internal static void MenuBackdrop(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -169,8 +167,8 @@ private static void Switch(TestContext ctx, Run run, Godot.Environment env, Godo run.Press(Down); } - ctx.Check(rows.Count == 20 && !rows.Exists(r => r.Contains("presentation", StringComparison.OrdinalIgnoreCase)), - $"the Options screen holds eighteen steppers and two doors and no presentation row ({string.Join(" | ", rows)})"); + ctx.Check(rows.Count == 21 && !rows.Exists(r => r.Contains("presentation", StringComparison.OrdinalIgnoreCase)), + $"the Options screen holds nineteen steppers and two doors and no presentation row ({string.Join(" | ", rows)})"); WalkTo(run, menu, "Apply and restart the menu"); run.Press(Accept); var applied = run.Expect(); diff --git a/CSVM/src/Testing/MenuNetPlaySuites.cs b/CSVM/src/Testing/MenuNetPlaySuites.cs index 43dfdb428..63e7e8b24 100644 --- a/CSVM/src/Testing/MenuNetPlaySuites.cs +++ b/CSVM/src/Testing/MenuNetPlaySuites.cs @@ -38,7 +38,6 @@ internal static class MenuNetPlaySuites + "screen with one pilot seated, and Back off the board hangs up")] internal static void TheMultiplayerDoor(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); var menu = LaunchMenu.Build(ctx.ZrdrPath, ctx.DataRoot, host, seat.Input); @@ -148,7 +147,6 @@ internal static void TheOnScreenKeyboard(TestContext ctx) + "says so, and a door with no clipboard copies nothing")] internal static void TheHostNamesAndCopiesItsAddress(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); const string Stable = "2a04:6ec0:232:6640:feb1:ff80:9ed7:dd90"; const string Lan = "192.168.178.20"; var copied = new List(); diff --git a/CSVM/src/Testing/MenuOriginalCampaignSuites.cs b/CSVM/src/Testing/MenuOriginalCampaignSuites.cs index 6f5ff370c..65e205455 100644 --- a/CSVM/src/Testing/MenuOriginalCampaignSuites.cs +++ b/CSVM/src/Testing/MenuOriginalCampaignSuites.cs @@ -72,6 +72,7 @@ internal static void MenuOriginalCampaign(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, Mech3.CampaignSequence.FileName); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); if (layout == null) diff --git a/CSVM/src/Testing/MenuOriginalCheatSuites.cs b/CSVM/src/Testing/MenuOriginalCheatSuites.cs index a0c268686..bcb5753db 100644 --- a/CSVM/src/Testing/MenuOriginalCheatSuites.cs +++ b/CSVM/src/Testing/MenuOriginalCheatSuites.cs @@ -24,7 +24,9 @@ internal static class MenuOriginalCheatSuites private const float Dt = 1f / 60f; private const string Pilot = "Zachary"; private const string Built = "Cheat Bird"; - private const string CabinPainting = "PC_BackGround.png"; + // The cabin painting's shipped name, which the page also falls back on when [@PassengerCabin@] + // authors no PC_BACKGROUND art. + private const string CabinFallback = "PC_BackGround.png"; // A point inside each screen's authored cheat region. No row of any of the three screens stands // in one, which is what lets the click be read before the hit test. @@ -58,7 +60,6 @@ internal static class MenuOriginalCheatSuites + "and the eleven stock airframes, leaving the screen standing and every airframe offered")] internal static void MenuOriginalCheats(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -107,7 +108,7 @@ internal static void MenuOriginalCheats(TestContext ctx) var size = ctx.Host.GetViewport().GetVisibleRect().Size; var fit = BoardFit.For(size.X, size.Y); Seat(ctx, host, seat, shell, fit, campaign); - Cabin(ctx, host, seat, shell, fit, campaign); + Cabin(ctx, host, seat, shell, fit, campaign, layout); Contents(ctx, host, seat, shell, fit, campaign); Hub(ctx, host, seat, shell, fit, campaign, store); UnlockingName(ctx, host, seat, shell, fit, campaign, store); @@ -144,7 +145,7 @@ private static void Seat(TestContext ctx, MenuHost host, ScriptedSeat seat, Orig // PASSENGERCABIN.SCRIPT's word: the click that gives the widget the keyboard, the two refusals, // the pull-down it shows, and the mission NEXT MISSION launches afterwards. private static void Cabin(TestContext ctx, MenuHost host, ScriptedSeat seat, OriginalShell shell, BoardFit fit, - CampaignFeature campaign) + CampaignFeature campaign, MenuLayout layout) { if (shell.Screen != OriginalScreen.CampaignCabin) { @@ -180,8 +181,17 @@ private static void Cabin(TestContext ctx, MenuHost host, ScriptedSeat seat, Ori // The field's paper is a fill and fills draw under the pictures, so the painting has to // stand in the backdrop for the closed field to show at all. var shown = shell.Compose(); - ctx.Check(HasArt(shown.Backdrop, CabinPainting) && !HasArt(shown.Pictures, CabinPainting) && shown.Fills.Count > 0, + string painting = layout.Screen("PassengerCabin")?.Widget("PC_BACKGROUND") is { Art.Count: > 0 } back + ? back.Art[0] + : CabinFallback; + ctx.Check(HasArt(shown.Backdrop, painting) && !HasArt(shown.Pictures, painting) && shown.Fills.Count > 0, $"the closed field's paper draws over the cabin painting ({shown.Backdrop.Count} backdrop, {shown.Fills.Count} fills)"); + // On the install the shipped name is pinned too, so a decode that renames the painting goes red. + if (!ctx.SyntheticData) + { + ctx.Check(HasArt(shown.Backdrop, CabinFallback) && !HasArt(shown.Pictures, CabinFallback) && shown.Fills.Count > 0, + $"the closed field's paper draws over the cabin painting ({shown.Backdrop.Count} backdrop, {shown.Fills.Count} fills)"); + } Click(host, seat, Pointer(fit, field.X + 5f, field.Y + 5f, pressed: true, clicked: true)); var entry = Row(shell, "ENTRY:" + PickedRow.ToString(CultureInfo.InvariantCulture)); diff --git a/CSVM/src/Testing/MenuOriginalConnectionSuites.cs b/CSVM/src/Testing/MenuOriginalConnectionSuites.cs index 5d44958ef..72ae7f6de 100644 --- a/CSVM/src/Testing/MenuOriginalConnectionSuites.cs +++ b/CSVM/src/Testing/MenuOriginalConnectionSuites.cs @@ -51,7 +51,6 @@ internal static class MenuOriginalConnectionSuites + "other the host closed the game and puts it back on the Connection page")] internal static void TheConnectionPage(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -157,7 +156,6 @@ internal static void TheConnectionPage(TestContext ctx) + "page, whose games list's Create Game opens a hosted lobby of its own")] internal static void TheLobby(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -255,7 +253,6 @@ internal static void TheLobby(TestContext ctx) + "host's list is read-only, and the guest's View... opens it read-only with no Accept")] internal static void TheOutlawList(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -337,7 +334,6 @@ internal static void TheOutlawList(TestContext ctx) + "field carries each seat's team")] internal static void TheLobbyTeams(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -646,7 +642,6 @@ internal static void TheLobbyRaceEnd(TestContext ctx) + "on that map and time in its own pick")] internal static void TheBuiltInHost(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -761,7 +756,6 @@ internal static void TheBuiltInHost(TestContext ctx) + "guest a patch apart from the host joins it")] internal static void TheVersionCheck(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -1157,7 +1151,6 @@ internal static void TheDiscoverySocket(TestContext ctx) + "is refused while a guest from another machine joins")] internal static void TheBootAndThePassword(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -1246,7 +1239,6 @@ internal static void TheBootAndThePassword(TestContext ctx) + "NETWORK, after which a new session admits it")] internal static void TheCoopBoot(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -2422,12 +2414,30 @@ private static void SetTheOptions(TestContext ctx, End host, End guest, List ends) { + // Each line is the data root's own row as the page trims it, so any table carrying the three + // reads the same. An empty or shared word would make every Draws below vacuous. + var table = CSVM.Mech3.UiStrings.TryLoad(ctx.DataRoot) ?? CSVM.Mech3.UiStrings.Empty; + string Word(int id) => table.Text(id).Trim().TrimStart(']'); + string deathmatch = Word(10123), ctf = Word(10124), zvz = Word(10125); + + // On the install the shipped words are pinned too, so a decode that garbles them goes red. const string Deathmatch = "Dogfight to the death."; const string Ctf = "Steal the opposing squadron's flag"; const string Zvz = "Protect your zeppelin"; - ctx.Check(Draws(host.Shell.Compose(), Deathmatch) && Draws(guest.Shell.Compose(), Deathmatch), + bool pinned = !ctx.SyntheticData; + ctx.Check(deathmatch.Length > 0 && ctf.Length > 0 && zvz.Length > 0 + && !deathmatch.Contains(ctf, StringComparison.Ordinal) && !ctf.Contains(deathmatch, StringComparison.Ordinal) + && !zvz.Contains(deathmatch, StringComparison.Ordinal), + $"the string table carries three distinct type descriptions ({deathmatch} / {ctf} / {zvz})"); + ctx.Check(Draws(host.Shell.Compose(), deathmatch) && Draws(guest.Shell.Compose(), deathmatch), $"both ends describe the Deathmatch under the Type box with langui 10123"); - foreach (var (type, want) in new[] { (DogfightMissionType.CaptureTheFlag, Ctf), (DogfightMissionType.ZeppelinVsZeppelin, Zvz) }) + if (pinned) + { + ctx.Check(Draws(host.Shell.Compose(), Deathmatch) && Draws(guest.Shell.Compose(), Deathmatch), + $"both ends describe the Deathmatch under the Type box with langui 10123"); + } + + foreach (var (type, want, shipped) in new[] { (DogfightMissionType.CaptureTheFlag, ctf, Ctf), (DogfightMissionType.ZeppelinVsZeppelin, zvz, Zvz) }) { host.Door.Dogfight!.SetMissionType(type); for (int frame = 0; frame < 4; frame++) @@ -2436,8 +2446,13 @@ private static void TypeDescriptions(TestContext ctx, End host, End guest, List< } var boards = new[] { host.Shell.Compose(), guest.Shell.Compose() }; - ctx.Check(boards.All(b => Draws(b, want) && !Draws(b, Deathmatch)), + ctx.Check(boards.All(b => Draws(b, want) && !Draws(b, deathmatch)), $"{type} is described on both ends with its own line and not the Deathmatch's ({guest.Door.Dogfight!.Options.MissionType})"); + if (pinned) + { + ctx.Check(boards.All(b => Draws(b, shipped) && !Draws(b, Deathmatch)), + $"{type} is described on both ends with its own line and not the Deathmatch's ({guest.Door.Dogfight!.Options.MissionType})"); + } } host.Door.Dogfight!.SetMissionType(DogfightMissionType.Deathmatch); @@ -2448,8 +2463,13 @@ private static void TypeDescriptions(TestContext ctx, End host, End guest, List< } var back = guest.Door.Dogfight!.Options; - ctx.Check(back is { MissionType: (byte)DogfightMissionType.Deathmatch, RestrictTeams: false } && Draws(guest.Shell.Compose(), Deathmatch), + ctx.Check(back is { MissionType: (byte)DogfightMissionType.Deathmatch, RestrictTeams: false } && Draws(guest.Shell.Compose(), deathmatch), $"and back on a Deathmatch without teams the guest reads 10123 again ({back.MissionType}, {back.RestrictTeams})"); + if (pinned) + { + ctx.Check(back is { MissionType: (byte)DogfightMissionType.Deathmatch, RestrictTeams: false } && Draws(guest.Shell.Compose(), Deathmatch), + $"and back on a Deathmatch without teams the guest reads 10123 again ({back.MissionType}, {back.RestrictTeams})"); + } } // The guest's second stock plane and a shell of its own on its first gun, picked while Ready, diff --git a/CSVM/src/Testing/MenuOriginalControlsSuites.cs b/CSVM/src/Testing/MenuOriginalControlsSuites.cs index e0de629cf..70dc7ab0c 100644 --- a/CSVM/src/Testing/MenuOriginalControlsSuites.cs +++ b/CSVM/src/Testing/MenuOriginalControlsSuites.cs @@ -37,7 +37,6 @@ internal static class MenuOriginalControlsSuites + "screen")] internal static void MenuOriginalControls(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -89,7 +88,7 @@ internal static void MenuOriginalControls(TestContext ctx) var size = ctx.Host.GetViewport().GetVisibleRect().Size; var fit = BoardFit.For(size.X, size.Y); var controls = host.Features.Get(); - Doors(ctx, host, seat, shell, fit, controls); + Doors(ctx, host, seat, shell, fit, controls, layout); Capture(ctx, host, seat, shell, fit, controls, written); Scrollbar(ctx, host, seat, shell, fit); Leave(ctx, host, seat, shell, fit, exits); @@ -106,7 +105,8 @@ internal static void MenuOriginalControls(TestContext ctx) // The three doors: the top level's PREFERENCES, the Options screen's CONTROLS and the CONTROLS // page's KEYS AND BUTTONS, each over the shared feature rather than a page-local copy. private static void Doors( - TestContext ctx, MenuHost host, ScriptedSeat seat, OriginalShell shell, BoardFit fit, ControlsFeature controls) + TestContext ctx, MenuHost host, ScriptedSeat seat, OriginalShell shell, BoardFit fit, ControlsFeature controls, + MenuLayout layout) { Click(host, seat, shell, fit, "MM_B_PREFERENCES"); ctx.Check(shell.Screen == OriginalScreen.Options, $"PREFERENCES opens the Options screen ({shell.Screen})"); @@ -148,17 +148,44 @@ private static void Doors( var accept = Row(shell, OriginalKeysPage.AcceptKey); ctx.Check(cancel != null && accept != null && cancel.X < accept.X && cancel.Y == accept.Y, $"CANCEL CHANGES is authored left of ACCEPT CHANGES on one line ({cancel?.X} vs {accept?.X})"); + // The authored heads are the section's own words, so the count reads the same over any layout. + var keys = layout.Screen("Keys"); + var wanted = new HashSet(StringComparer.Ordinal) { "Stick" }; + foreach (string key in new[] { "KB_T_COMMANDTITLE", "KB_T_CONTTITLEA", "KB_T_CONTTITLEB" }) + { + if (keys?.Widget(key)?.Text is { Length: > 0 } text) + { + wanted.Add(text); + } + } + var board = shell.Compose(); int heads = 0; foreach (var line in board.Lines) { - if (line.Text is "Action" or "Control A" or "Control B" or "Stick") + if (wanted.Contains(line.Text)) { heads++; } } - ctx.Check(heads == 4, $"the page draws its three authored column heads and the port's Stick head ({heads})"); + ctx.Check(wanted.Count == 4 && heads == 4, + $"the page draws its three authored column heads and the port's Stick head ({heads} of {string.Join(", ", wanted)})"); + + // On the install the shipped words are pinned too, so a decode that garbles them goes red. + if (!ctx.SyntheticData) + { + int shipped = 0; + foreach (var line in board.Lines) + { + if (line.Text is "Action" or "Control A" or "Control B" or "Stick") + { + shipped++; + } + } + + ctx.Check(shipped == 4, $"the page draws its three authored column heads and the port's Stick head ({shipped})"); + } } // A capture armed on one cell: Escape abandons it, a second one binds, and ACCEPT CHANGES is diff --git a/CSVM/src/Testing/MenuOriginalCoopFilmSuites.cs b/CSVM/src/Testing/MenuOriginalCoopFilmSuites.cs index 12ffa07cf..bf6115fd9 100644 --- a/CSVM/src/Testing/MenuOriginalCoopFilmSuites.cs +++ b/CSVM/src/Testing/MenuOriginalCoopFilmSuites.cs @@ -35,7 +35,6 @@ internal static class MenuOriginalCoopFilmSuites + "rather than missing it")] internal static void TheCoopFilms(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); diff --git a/CSVM/src/Testing/MenuOriginalCoopFlowSuites.cs b/CSVM/src/Testing/MenuOriginalCoopFlowSuites.cs index 4db97e537..8e39ea858 100644 --- a/CSVM/src/Testing/MenuOriginalCoopFlowSuites.cs +++ b/CSVM/src/Testing/MenuOriginalCoopFlowSuites.cs @@ -43,7 +43,6 @@ internal static class MenuOriginalCoopFlowSuites + "untouched")] internal static void TheCoopFlow(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); diff --git a/CSVM/src/Testing/MenuOriginalSuites.cs b/CSVM/src/Testing/MenuOriginalSuites.cs index 5d71b1404..d5406a93a 100644 --- a/CSVM/src/Testing/MenuOriginalSuites.cs +++ b/CSVM/src/Testing/MenuOriginalSuites.cs @@ -85,10 +85,10 @@ internal static class MenuOriginalSuites + "its six display rows over the machine's own screens and sizes, the four vocabularies " + "and a wrap onto the last frame cap and, past the three scales below native, onto the last render scale, an anti-aliasing row " + "on the stepped mode's default whose step onto FSR 2.2 pulls that scale back to native " - + "and narrows the scale row, a shadow-quality row that steps only under Enhanced, its four volume rows " + + "and narrows the scale row, a shadow-quality row and a water-quality row that step only under Enhanced, its four volume rows " + "stepped by the AUDIO page's own step and clamped at both ends, " + "opens and leaves the rebinding screen behind its Controls door and emits the apply exit " - + "carrying all eighteen with no presentation row among them, " + + "carrying all nineteen with no presentation row among them, " + "Original's VIDEO door opens the decoded page on its Display Mode dropdown " + "which fits its authored window and draws no bar, over the V-Sync one whose five words " + "window into four with the arrows and the thumb inside the box's right edge and the fifth " @@ -189,7 +189,6 @@ internal static void MenuOriginalTracer(TestContext ctx) + "longer inviting a pad's START")] internal static void MenuJoinBoard(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -656,9 +655,9 @@ private static void SwitchToBuiltIn(TestContext ctx, MenuHost host, ScriptedSeat // Built-in's Options route: the last Mode row opens Options, and Right steps the difficulty to // Hard. The two rows under it step the opening view and the automatic head turn, the two under // those the targeting setting and the rumble. The graphics mode and view distance rows come next; - // the command line alone chooses a presentation, so no row does. The six display rows step over the - // machine's own screens and sizes. The four volume rows step the levels the AUDIO page writes. - // The apply row's Accept is the one exit the launcher persists every choice from. + // the command line alone chooses a presentation, so no row does. The display, shadow, water and + // volume rows step their own words, levels, screens and sizes. The apply row's Accept is the one + // exit the launcher persists every choice from. private static void BuiltInOptionsRoute(TestContext ctx, MenuHost host, ScriptedSeat seat, List exits) { var menu = (host.Active as BuiltInPresentation)?.Menu; @@ -672,8 +671,8 @@ private static void BuiltInOptionsRoute(TestContext ctx, MenuHost host, Scripted ctx.Check(menu.ShownRowText == LaunchMenu.OptionsRow, $"Up from Free Flight wraps onto the multiplayer door, and again onto Options ({menu.ShownRowText})"); Press(host, seat, Accept); - ctx.Check(menu.ShownScreen == "Options" && menu.ShownRowCount == 20 && menu.ShownRowText == "Difficulty: Normal", - $"Accept opens the Options screen with its twenty rows, the difficulty stepper first ({menu.ShownScreen}, {menu.ShownRowCount}, {menu.ShownRowText})"); + ctx.Check(menu.ShownScreen == "Options" && menu.ShownRowCount == 21 && menu.ShownRowText == "Difficulty: Normal", + $"Accept opens the Options screen with its twenty-one rows, the difficulty stepper first ({menu.ShownScreen}, {menu.ShownRowCount}, {menu.ShownRowText})"); Press(host, seat, Right); ctx.Check(menu.ShownRowText == "Difficulty: Hard", $"Right steps the difficulty to Hard ({menu.ShownRowText})"); Press(host, seat, Down); @@ -722,10 +721,11 @@ private static void BuiltInOptionsRoute(TestContext ctx, MenuHost host, Scripted $"a step moves it under Enhanced and not under Original, whose detail says why ({graphics}: {viewDistance} -> {menu.ShownRowText}, {menu.ShownDetail})"); var display = BuiltInDisplayRows(ctx, host, seat, menu, graphics); string? shadowQuality = BuiltInShadowQualityRow(ctx, host, seat, menu, graphics); + string? waterQuality = BuiltInWaterQualityRow(ctx, host, seat, menu, graphics); BuiltInAudioRows(ctx, host, seat, menu); Press(host, seat, Down); - ctx.Check(menu.ShownRowText == LaunchMenu.ControlsRow && menu.ShownHeading == "OPTIONS (19/20)", - $"the nineteenth row is the Controls door, the heading counting the window's position ({menu.ShownRowText}, {menu.ShownHeading})"); + ctx.Check(menu.ShownRowText == LaunchMenu.ControlsRow && menu.ShownHeading == "OPTIONS (20/21)", + $"the twentieth row is the Controls door, the heading counting the window's position ({menu.ShownRowText}, {menu.ShownHeading})"); Press(host, seat, Accept); ctx.Check(menu.ShownScreen == "Controls" && menu.ShownRowCount > 2, $"which opens the rebinding screen over a seat's own keymap ({menu.ShownScreen}, {menu.ShownRowCount} rows)"); @@ -749,6 +749,8 @@ private static void BuiltInOptionsRoute(TestContext ctx, MenuHost host, Scripted $"and all six display settings the rows stepped ({applied.MonitorIndex}, {applied.Resolution}, {applied.DisplayMode}, {applied.VSync}, {applied.RenderScale}, {applied.AntiAliasing})"); ctx.Check(applied.ShadowQuality == shadowQuality, $"and the shadow quality the row left, never set where the row stood dead ({applied.ShadowQuality ?? "unset"})"); + ctx.Check(applied.WaterQuality == waterQuality, + $"and the water quality the row left, never set where the row stood dead ({applied.WaterQuality ?? "unset"})"); ctx.Check(applied.AudioMaster == null && applied.AudioMusic == AudioMix.DefaultMusic - SliderControl.KeyStep && applied.AudioEffects == null && applied.AudioVoice == AudioMix.MinLevel, $"and the levels the volume rows moved, the two left alone still never set ({Level(applied.AudioMaster)}, {Level(applied.AudioMusic)}, {Level(applied.AudioEffects)}, {Level(applied.AudioVoice)})"); @@ -872,14 +874,37 @@ private static (string? Monitor, string? Resolution, string? DisplayMode, string return ShadowQualitySetting.Low; } - // The four volume rows, walked from the shadow-quality row on a mix nothing has saved. Each shows + // The water-quality row under the shadow-quality row, on a store nothing has saved. It shows the + // word the run resolved and steps only under Enhanced, since the faithful sea is always flat. + // Returns the word the apply carries, null where the row stood dead. + private static string? BuiltInWaterQualityRow(TestContext ctx, MenuHost host, ScriptedSeat seat, LaunchMenu menu, string graphics) + { + string standing = WaterQualitySetting.Label(null); + Press(host, seat, Down); + ctx.Check(menu.ShownRowText == $"Water quality: {standing}" && menu.ShownHeading == "OPTIONS (15/21)", + $"the fifteenth row is the water quality, unsaved showing the run's own word ({menu.ShownRowText}, {menu.ShownHeading})"); + Press(host, seat, Right); + if (graphics != GraphicsMode.EnhancedWord) + { + ctx.Check(menu.ShownRowText == $"Water quality: {standing}" && menu.ShownDetail.StartsWith("Enhanced Graphics only", System.StringComparison.Ordinal), + $"under Original Right steps it nowhere, and the row says why ({menu.ShownRowText}, {menu.ShownDetail})"); + return null; + } + + string other = WaterQualitySetting.Word == WaterQualitySetting.Waves ? WaterQualitySetting.Flat : WaterQualitySetting.Waves; + ctx.Check(menu.ShownRowText == $"Water quality: {WaterQualitySetting.Label(other)}", + $"under Enhanced Right steps it onto the other word ({menu.ShownRowText})"); + return other; + } + + // The four volume rows, walked from the water-quality row on a mix nothing has saved. Each shows // the shipped level, steps by the AUDIO page's own keyboard step, and clamps at both ends. A step // that moves nothing writes nothing, so Master stepped up off full stays never set. private static void BuiltInAudioRows(TestContext ctx, MenuHost host, ScriptedSeat seat, LaunchMenu menu) { Press(host, seat, Down); - ctx.Check(menu.ShownRowText == $"Master volume: {AudioMix.DefaultMaster}" && menu.ShownHeading == "OPTIONS (15/20)", - $"the fifteenth row is the Master level, unsaved showing the shipped full level ({menu.ShownRowText}, {menu.ShownHeading})"); + ctx.Check(menu.ShownRowText == $"Master volume: {AudioMix.DefaultMaster}" && menu.ShownHeading == "OPTIONS (16/21)", + $"the sixteenth row is the Master level, unsaved showing the shipped full level ({menu.ShownRowText}, {menu.ShownHeading})"); Press(host, seat, Right); ctx.Check(menu.ShownRowText == $"Master volume: {AudioMix.MaxLevel}", $"Right at full clamps rather than wrapping to silence ({menu.ShownRowText})"); diff --git a/CSVM/src/Testing/MenuPlayerSetupSuites.cs b/CSVM/src/Testing/MenuPlayerSetupSuites.cs index 87b766ff6..7fa8ff341 100644 --- a/CSVM/src/Testing/MenuPlayerSetupSuites.cs +++ b/CSVM/src/Testing/MenuPlayerSetupSuites.cs @@ -44,7 +44,6 @@ internal static class MenuPlayerSetupSuites + "and dropping the picks")] internal static void MenuPlayerSetupJourney(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var exits = new List(); var host = MenuSuiteHost.Bare(exits, ctx.DataRoot, out var seat); var menu = MenuSuiteHost.Build(ctx, host, seat, "menu-player-setup-journey"); @@ -85,7 +84,6 @@ internal static void MenuPlayerSetupJourney(TestContext ctx) + "from the top level")] internal static void MenuPlayerSetupSeats(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); BuiltInSeats(ctx); OriginalSeats(ctx); } @@ -100,7 +98,6 @@ internal static void MenuPlayerSetupSeats(TestContext ctx) + "row and its staged edits with it")] internal static void MenuControlsSeats(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string dir = System.IO.Path.Combine(ctx.ScratchDir, "menu-controls-seats"); System.IO.Directory.CreateDirectory(dir); // ⚠ Before the host: the save below writes through the store, and without the override it diff --git a/CSVM/src/Testing/MenuZoneSuites.cs b/CSVM/src/Testing/MenuZoneSuites.cs index 867ba1806..43148c5cb 100644 --- a/CSVM/src/Testing/MenuZoneSuites.cs +++ b/CSVM/src/Testing/MenuZoneSuites.cs @@ -26,7 +26,6 @@ internal static class MenuZoneSuites + "under it is resized and the layout must follow with no press to prompt it")] internal static void MenuZoneLayout(TestContext ctx) { - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); var view = new SubViewport { Size = SmallWindow, diff --git a/CSVM/src/Testing/MouseFlightSuites.cs b/CSVM/src/Testing/MouseFlightSuites.cs index c2b0ae97e..e17b68393 100644 --- a/CSVM/src/Testing/MouseFlightSuites.cs +++ b/CSVM/src/Testing/MouseFlightSuites.cs @@ -38,6 +38,7 @@ internal static class MouseFlightSuites internal static void FlightMouseScheme(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane("player_autogyro", "player_bhawk"); var gyroStats = PlaneStats.Load(ctx.ZrdrPath, "player_autogyro"); var planeStats = PlaneStats.Load(ctx.ZrdrPath, "player_bhawk"); ctx.Check(gyroStats.IsAutogyro && !planeStats.IsAutogyro, @@ -99,6 +100,7 @@ internal static void FlightMouseScheme(TestContext ctx) internal static void FlightMouseCapture(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane("player_bhawk"); var scripted = FlightRosterPolicy.From(SessionSpec.Parse(new[] { "--run-tests" })); var interactive = FlightRosterPolicy.From(SessionSpec.Parse(new[] { "--fly" })); bool realDisplay = DisplayServer.GetName() != "headless"; @@ -157,6 +159,7 @@ internal static void FlightMouseSchemeLive(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(UI.Menu.MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); + ctx.RequirePlane("player_bhawk"); var layout = UI.Menu.Original.OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); if (layout == null) diff --git a/CSVM/src/Testing/MusicSuites.cs b/CSVM/src/Testing/MusicSuites.cs index aec50f3cd..2f261a69a 100644 --- a/CSVM/src/Testing/MusicSuites.cs +++ b/CSVM/src/Testing/MusicSuites.cs @@ -26,6 +26,8 @@ internal static class MusicSuites + "releases it")] internal static void MusicStates(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireData(ctx.SoundsPath, $"sound archive (soundsh)"); var defs = SoundDefs.Load(ctx.ZrdrPath); var groups = SoundDefs.LoadGroups(ctx.ZrdrPath); using var archive = new SoundArchive(ctx.SoundsPath); diff --git a/CSVM/src/Testing/NetBotYieldSuites.cs b/CSVM/src/Testing/NetBotYieldSuites.cs index d022ee288..cad954218 100644 --- a/CSVM/src/Testing/NetBotYieldSuites.cs +++ b/CSVM/src/Testing/NetBotYieldSuites.cs @@ -49,6 +49,8 @@ internal static void ALateJoinerTakesTheNewestBotsSeat(TestContext ctx) { var spec = NetCombatSuites.MatchSpec(ctx, out _, "--vs-time=1"); ctx.RequireData(ctx.MessagesPath, $"message table"); + // Fifteen Random bots draw from every stock airframe. + ctx.RequirePlane(StockAirframes.Nodes.ToArray()); var pool = BotSeats.CallsignPool(CSVM.Mech3.Messages.Load(ctx.MessagesPath)); var mesh = LoopbackTransport.Mesh(3, LoopbackConditions.Perfect, new Random(5121)); var gate = new ArrivalGate(mesh[0]); diff --git a/CSVM/src/Testing/NetChatSuites.cs b/CSVM/src/Testing/NetChatSuites.cs index 4cb4f9912..dce073235 100644 --- a/CSVM/src/Testing/NetChatSuites.cs +++ b/CSVM/src/Testing/NetChatSuites.cs @@ -35,7 +35,7 @@ internal static void ChatAcrossThreeMachines(TestContext ctx) { var spec = NetCombatSuites.MatchSpec(ctx, out _); var mesh = LoopbackTransport.Mesh(3, LoopbackConditions.Perfect, new Random(8601)); - var roster = NetCombatSuites.Roster(3).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(3, spec).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try diff --git a/CSVM/src/Testing/NetCombatSuites.cs b/CSVM/src/Testing/NetCombatSuites.cs index b43d8cad7..f220aa681 100644 --- a/CSVM/src/Testing/NetCombatSuites.cs +++ b/CSVM/src/Testing/NetCombatSuites.cs @@ -81,7 +81,8 @@ internal static class NetCombatSuites // NetClockSlew.SnapSeconds on purpose: a snap is countable and a walk is not. private const double SlewLeadSeconds = 6.0; - // The airframe order every peer reads a roster's airframe index against. + // The airframe order every peer reads a roster's airframe index against. Two different + // entries, so a seat's pick crossing the wire cannot be satisfied by both ends sharing a default. private static readonly string[] Airframes = { "player_pfighter", "player_fbrand" }; [Suite("net-combat-events", @@ -101,7 +102,7 @@ internal static void CombatEventsCrossTheWire(TestContext ctx) // A clean link: every assertion below is about a rule, and a dropped round would read as // a broken rule. The lossy link is asserted on in net-aircraft-replication. var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(4211)); - var roster = Roster(2); + var roster = Roster(2, spec); var weapons = WeaponDefs.Load(ctx.ZrdrPath); var gun = weapons.All.FirstOrDefault(w => w.IsCannon && w.HealthDamage is > 0f); if (gun == null) @@ -157,7 +158,7 @@ internal static void TheHostRelaysBetweenGuests(TestContext ctx) // The star itself, cut before any session binds. Neither guest ever sees the other as a // peer, so anything that reaches it came through the host. mesh[1].Disconnect(2); - var roster = Roster(3); + var roster = Roster(3, spec); var ambient = Ambient.Save(); Ends? host = null; @@ -212,7 +213,7 @@ internal static void TheHostGrantsEverySpawn(TestContext ctx) // The star, cut before any session binds. An ask reaches the host alone and a grant comes // back from it alone, so nothing here is two guests agreeing between themselves. mesh[1].Disconnect(2); - var roster = Roster(3); + var roster = Roster(3, spec); var ambient = Ambient.Save(); Ends? host = null; @@ -272,7 +273,7 @@ internal static void MatchStateIsTheHostsAlone(TestContext ctx) // The star, cut before any session binds. A guest's state came from the host or from // nowhere, since the two guests never see each other at all. mesh[1].Disconnect(2); - var roster = Roster(3); + var roster = Roster(3, spec); var ambient = Ambient.Save(); Ends? host = null; @@ -398,13 +399,16 @@ internal static void EveryMachinePostsTheKill(TestContext ctx) var host = peers[0]; var guest = peers[1]; - KillLines(ctx, peers, "the guest kills the host", "host", "Destroyed by guest1", + var words = MatchWording.Read(ctx); + words.Check(ctx, Array.Empty(), + new[] { HudMessages.DestroyedByKey, HudMessages.SelfDestroyedKey, HudMessages.KilledByTurretKey }); + KillLines(ctx, peers, "the guest kills the host", ("host", words.DestroyedBy("guest1")), ("host", "Destroyed by guest1"), () => host.SeatRigs[0].Controller!.DebugForceCrash(host.SeatRigs[1].Controller!.PlayerIndex)); - KillLines(ctx, peers, "the host kills the guest", "guest1", "Destroyed by host", + KillLines(ctx, peers, "the host kills the guest", ("guest1", words.DestroyedBy("host")), ("guest1", "Destroyed by host"), () => guest.SeatRigs[1].Controller!.DebugForceCrash(guest.SeatRigs[0].Controller!.PlayerIndex)); - KillLines(ctx, peers, "the guest crashes with nobody to charge", "guest1 Self-Destroyed", null, - () => guest.SeatRigs[1].Controller!.DebugForceCrash()); - KillLines(ctx, peers, "a turret owner's kill", "guest1", "Killed by host Turret", + KillLines(ctx, peers, "the guest crashes with nobody to charge", (words.SelfDestroyed("guest1"), null), + ("guest1 Self-Destroyed", null), () => guest.SeatRigs[1].Controller!.DebugForceCrash()); + KillLines(ctx, peers, "a turret owner's kill", ("guest1", words.KilledByTurret("host")), ("guest1", "Killed by host Turret"), () => guest.Wire.Link!.Send(guest.Wire.Link.HostPeer, new DeathMessage(1, 0, NetDeathCause.TurretOwner, 0u), NetChannels.Events)); foreach (var end in Enumerable.Reverse(ends)) @@ -412,7 +416,7 @@ internal static void EveryMachinePostsTheKill(TestContext ctx) end.Close(); } - NamedHostKills(ctx, spec, 6308); + NamedHostKills(ctx, spec, 6308, words); } finally { @@ -435,28 +439,64 @@ internal static SessionSpec MatchSpec(TestContext ctx, out IReadOnlyList - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--mute", - "--no-pads", - }; + var args = new List { "--vs" }; + args.AddRange(Arena(ctx)); + args.AddRange(new[] { "--players=1", "--mute", "--no-pads" }); args.AddRange(extraArgs); var spec = SessionSpec.Parse(args.ToArray()); var loaded = new SpawnPicker(spec).LoadSpawnList(missionZrdr, spec.Scenario); if (loaded is not { Count: >= 2 }) { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); + throw new SuiteSkippedException($"{ArenaTable(ctx, spec)} holds no usable spawn table"); } table = loaded; return spec; } + // A match flies the data root's MP map when the root carries one, so a run on the install keeps + // walking the shipped table. Otherwise it flies the empty stage, its own spawn table and its + // arena of flags and rearm nodes. The MP map's gates run only when it is chosen, and the note + // names the arena in the report. A mode with its own map names it, as Capture the Flag does MP2. + internal static string[] Arena(TestContext ctx, bool gateChapterGamez = true, string mission = MpMission) + { + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, mission); + if (!System.IO.File.Exists(missionZrdr) && !System.IO.Directory.Exists(missionZrdr)) + { + ctx.Note($"arena: --stage=empty and its spawn ring, the data root carries no {ctx.Chapter}/{mission}"); + return new[] { "--stage=empty" }; + } + + if (gateChapterGamez) + { + ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); + } + + ctx.Note($"arena: {ctx.Chapter}/{mission} and its net.zrd table"); + return new[] { $"--chapter={ctx.Chapter}", $"--mission={mission}" }; + } + + // What a skip names as the table a match walked, so an empty-stage skip points at the ring. + internal static string ArenaTable(TestContext ctx, SessionSpec spec) => + spec.EmptyStage ? "the empty stage's spawn ring" : $"{ctx.Chapter}/{MpMission}'s net.zrd"; + + // The airframe order a match's peers read a seat's airframe index against. The empty arena + // stands on a data root with no MP map, so its seats fly the plane that root defaults to. + // Two entries either way, so a suite indexing seat 1 reads the same on both. One airframe + // there cannot show a pick crossing the wire; the run on the install still does. + internal static string[] AirframesFor(SessionSpec spec) => + spec.EmptyStage ? new[] { spec.PlaneName, spec.PlaneName } : Airframes; + + // Two different airframes, for a suite whose check is a seat's pick crossing the wire. The + // empty arena seats the root's own plane beside the lobby's starter. A synthetic tree so flies + // both of its invented airframes there, and the MP map keeps the shipped pair. + internal static string[] DistinctAirframesFor(SessionSpec spec) => + spec.EmptyStage ? new[] { spec.PlaneName, Airframes[0] } : Airframes; + // The field, seat 0 on the host and one seat per guest after it. - internal static NetSeat[] Roster(int seats) + internal static NetSeat[] Roster(int seats, SessionSpec match) { + var airframes = AirframesFor(match); var roster = new NetSeat[seats]; for (int i = 0; i < seats; i++) { @@ -466,7 +506,7 @@ internal static NetSeat[] Roster(int seats) SeatIndex = i, FlownHere = i == 0, Callsign = i == 0 ? "host" : $"guest{i}", - PlaneNode = Airframes[i % Airframes.Length], + PlaneNode = airframes[i % airframes.Length], }; } @@ -474,10 +514,11 @@ internal static NetSeat[] Roster(int seats) return roster; } - // One death on clean stacks: each machine's own pane then reads exactly the decoded lines, - // top line newest. A relayed copy posted twice, or one machine silent, fails it. - private static void KillLines(TestContext ctx, GameSession[] peers, string what, string top, - string? under, Action kill, Action? fly = null) + // One death on clean stacks: each machine's own pane then reads exactly the lines the run's + // table words, top line newest. A relayed copy posted twice, or one machine silent, fails it. + // A real extraction's panes are also read against the shipped wording, `shipped`. + private static void KillLines(TestContext ctx, GameSession[] peers, string what, + (string Top, string? Under) worded, (string Top, string? Under) shipped, Action kill, Action? fly = null) { fly ??= steps => Lockstep(steps, peers); fly(GrantSteps); @@ -489,7 +530,9 @@ private static void KillLines(TestContext ctx, GameSession[] peers, string what, kill(); fly(SettleSteps); - var want = under == null ? new[] { top } : new[] { top, under }; + string[] Lines((string Top, string? Under) pair) => + pair.Under == null ? new[] { pair.Top } : new[] { pair.Top, pair.Under }; + var want = Lines(worded); // The victim's own crash notice is the ground impact's line, not the kill's, and is left out. string crash = Messages.Load(ctx.MessagesPath).Get(HudMessages.CrashKey); string[] Read(HudMessages? stack) => stack == null @@ -499,14 +542,20 @@ string[] Read(HudMessages? stack) => stack == null var reads = stacks.Select(Read).ToArray(); string reading = string.Join(" | ", reads.Select(r => string.Join(" / ", r))); ctx.Check(reads.All(r => r.SequenceEqual(want)), - $"{what}: both machines post {string.Join(" / ", want)} once ({reading})"); + $"{what}: both machines post the message table's {string.Join(" / ", want)} once ({reading})"); + if (!ctx.SyntheticData) + { + var old = Lines(shipped); + ctx.Check(reads.All(r => r.SequenceEqual(old)), + $"{what}: both machines post {string.Join(" / ", old)} once ({reading})"); + } } // A lobby Dogfight launched through both doors, its host's callsign past the roster's width // and its game named apart from it. The host's seat takes the callsign, cut where the wire cuts // it, and the guest's seat the callsign its pick carried. Both machines then read those names // in a kill line. - private static void NamedHostKills(TestContext ctx, SessionSpec spec, int seed) + private static void NamedHostKills(TestContext ctx, SessionSpec spec, int seed, MatchWording words) { const string HostName = "Montgomery Fairweather"; const string GameName = "Friday Fliers"; @@ -580,9 +629,9 @@ private static void NamedHostKills(TestContext ctx, SessionSpec spec, int seed) } string guestName = roster[1].Callsign; - KillLines(ctx, peers, "[named host] the host kills the guest", guestName, $"Destroyed by {named}", + KillLines(ctx, peers, "[named host] the host kills the guest", (guestName, words.DestroyedBy(named)), (guestName, $"Destroyed by {named}"), () => guest.Session.SeatRigs[1].Controller!.DebugForceCrash(guest.Session.SeatRigs[0].Controller!.PlayerIndex), fly); - KillLines(ctx, peers, "[named host] the guest kills the host", named, $"Destroyed by {guestName}", + KillLines(ctx, peers, "[named host] the guest kills the host", (named, words.DestroyedBy(guestName)), (named, $"Destroyed by {guestName}"), () => host.Session.SeatRigs[0].Controller!.DebugForceCrash(host.Session.SeatRigs[1].Controller!.PlayerIndex), fly); } finally @@ -727,7 +776,7 @@ private static void StepDoors(int steps, params UI.Menu.NetPlayFeature[] doors) private static GameSession[]? Pair(TestContext ctx, SessionSpec spec, int seed, List ends) { var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(seed)); - var host = Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, Roster(2)); + var host = Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, Roster(2, spec)); ends.Add(host); var guest = Ends.Open(ctx, spec, mesh[1], isHost: false, HostSeed + 1, null); ends.Add(guest); @@ -763,9 +812,18 @@ private static void LastLife(TestContext ctx, GameSession[] peers) ctx.Check(peers.All(p => !p.Dogfight!.Match.Completed && p.Dogfight!.End == NetMatchEnd.Running) && guest.SeatRigs[1].Controller!.Watching == null, $"ABLE-TO-FAIL CONTROL: with a life left the match runs and the seat watches nobody ({string.Join(", ", peers.Select(p => p.Dogfight!.End))})"); + var words = MatchWording.Read(ctx); + words.Check(ctx, new[] { HudMessages.OneLifeKey, HudMessages.GameOverKey, HudMessages.AllAloneKey }, + new[] { HudMessages.DestroyedByKey }); + string killed = $"guest1 / {words.DestroyedBy("host")}"; string first = PaneLines(guest.SeatRigs[1].Controller!); - ctx.Check(first.StartsWith("guest1 / Destroyed by host / You Have ONE Life Left!", StringComparison.Ordinal), - $"the guest's pane reads the kill lines over its lives line, the handler's order ({first})"); + ctx.Check(first.StartsWith($"{killed} / {words.Row(HudMessages.OneLifeKey)}", StringComparison.Ordinal), + $"the guest's pane reads the kill lines over its lives line, the handler's order, in the message table's lines ({first})"); + if (!ctx.SyntheticData) + { + ctx.Check(first.StartsWith("guest1 / Destroyed by host / You Have ONE Life Left!", StringComparison.Ordinal), + $"the guest's pane reads the kill lines over its lives line, the handler's order ({first})"); + } int grants = peers[0].Dogfight!.SpawnsTaken; guest.SeatRigs[1].Controller!.DebugForceCrash(guest.SeatRigs[0].Controller!.PlayerIndex); @@ -783,8 +841,13 @@ private static void LastLife(TestContext ctx, GameSession[] peers) $"one pilot with lives left ends the match on reason 4 on both machines ({string.Join(", ", peers.Select(p => p.Dogfight!.End))})"); // Five lines into four slots: the ending and the kill lines push the lives line out. string lines = PaneLines(guest.SeatRigs[1].Controller!); - ctx.Check(lines.StartsWith("Game Over: / No Enemies Left / guest1 / Destroyed by host", StringComparison.Ordinal), - $"and the guest's pane reads the ending over the last kill ({lines})"); + ctx.Check(lines.StartsWith($"{words.Row(HudMessages.GameOverKey)} / {words.Row(HudMessages.AllAloneKey)} / {killed}", StringComparison.Ordinal), + $"and the guest's pane reads the ending over the last kill, in the message table's lines ({lines})"); + if (!ctx.SyntheticData) + { + ctx.Check(lines.StartsWith("Game Over: / No Enemies Left / guest1 / Destroyed by host", StringComparison.Ordinal), + $"and the guest's pane reads the ending over the last kill ({lines})"); + } } private static string PaneLines(FlightController pilot) => @@ -1493,6 +1556,35 @@ public void Restore() } } + // The Dogfight lines a pane posts, read off the run's own message table, so a check follows + // whichever table that is. A suite checks the rows first: a missing row reads as its key, + // which a pane posting the bare key would match. + internal sealed class MatchWording + { + private readonly Messages _rows; + + private MatchWording(Messages rows) => _rows = rows; + + internal static MatchWording Read(TestContext ctx) => new(Messages.Load(ctx.MessagesPath)); + + internal string Row(string key) => _rows.Get(key); + + internal string DestroyedBy(string killer) => Messages.Fill(Row(HudMessages.DestroyedByKey), killer); + + internal string SelfDestroyed(string victim) => Messages.Fill(Row(HudMessages.SelfDestroyedKey), victim); + + internal string KilledByTurret(string owner) => Messages.Fill(Row(HudMessages.KilledByTurretKey), owner); + + // Every key worded, and every row in `named` taking the name it is filled with. + internal void Check(TestContext ctx, string[] keys, string[] named) + { + var missing = keys.Concat(named).Where(key => Row(key) == key).ToArray(); + var nameless = named.Where(key => !Row(key).Contains("%1", StringComparison.Ordinal)).ToArray(); + ctx.Check(missing.Length == 0 && nameless.Length == 0, + $"the message table words every line the panes are read against, and each name row takes a name (missing {string.Join(",", missing)}; no %1 in {string.Join(",", nameless)})"); + } + } + // One peer's whole rig: its own pane, its own world, its own session node. With no transport // it is a local match, whose roster a host's argument carries as the launcher hands one over. internal sealed record Ends(SubViewport Pane, GameSession Session, bool Built) @@ -1518,6 +1610,7 @@ public static Ends Open(TestContext ctx, SessionSpec spec, INetTransport? transp ctx.Host.AddChild(pane); var session = new GameSession(spec, new LauncherContext { + Decode = ctx.Decode, RepoRoot = ctx.RepoRoot, DataRoot = ctx.DataRoot, PlanesGamezPath = ctx.PlanesGamezPath, @@ -1544,7 +1637,7 @@ public static Ends Open(TestContext ctx, SessionSpec spec, INetTransport? transp NetSeats = isHost ? roster : null, NetTransport = transport, NetHost = isHost && transport != null, - NetAirframes = transport == null ? null : airframes ?? Airframes, + NetAirframes = transport == null ? null : airframes ?? AirframesFor(spec), NetSeatFit = seatFit, NetCoopWingman = coopWingman, NetSeatBuild = seatBuild, diff --git a/CSVM/src/Testing/NetCoopGuestPlaneSuites.cs b/CSVM/src/Testing/NetCoopGuestPlaneSuites.cs index 7dc54d5cf..b76487723 100644 --- a/CSVM/src/Testing/NetCoopGuestPlaneSuites.cs +++ b/CSVM/src/Testing/NetCoopGuestPlaneSuites.cs @@ -50,6 +50,7 @@ internal static class NetCoopGuestPlaneSuites internal static void EverySeatPicksItsOwnPlane(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath).ToArray(); var solo = missions.Cast().FirstOrDefault(m => !m!.Value.Wingman) ?? throw new SuiteSkippedException($"cm_sequence carries no mission without a wingman"); diff --git a/CSVM/src/Testing/NetCoopMissionSuites.cs b/CSVM/src/Testing/NetCoopMissionSuites.cs index af96618cf..59f622087 100644 --- a/CSVM/src/Testing/NetCoopMissionSuites.cs +++ b/CSVM/src/Testing/NetCoopMissionSuites.cs @@ -547,6 +547,7 @@ private static CampaignMission IntroMission(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.ChapterFolder.Equals("C1", StringComparison.OrdinalIgnoreCase) && m.Value.MissionFolder.Equals("M04", StringComparison.OrdinalIgnoreCase)) @@ -639,6 +640,7 @@ private static CampaignMission CabbieMission(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.ChapterFolder.Equals("C5", StringComparison.OrdinalIgnoreCase) && m.Value.MissionFolder.Equals("M01", StringComparison.OrdinalIgnoreCase)) @@ -710,6 +712,7 @@ private static CampaignMission WingmanMission(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.ChapterFolder.Length > 0 && m.Value.Wingman) ?? throw new SuiteSkippedException($"cm_sequence carries no wingman mission with a chapter"); @@ -1193,6 +1196,7 @@ private static CampaignMission Mission(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.ChapterFolder.Length > 0) ?? throw new SuiteSkippedException($"cm_sequence carries no mission with a chapter"); diff --git a/CSVM/src/Testing/NetCutsceneSuites.cs b/CSVM/src/Testing/NetCutsceneSuites.cs index 4a586502a..da125e590 100644 --- a/CSVM/src/Testing/NetCutsceneSuites.cs +++ b/CSVM/src/Testing/NetCutsceneSuites.cs @@ -50,6 +50,7 @@ internal static class NetCutsceneSuites internal static void RemoteOwner(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.Seq == FirstSeq) diff --git a/CSVM/src/Testing/NetDirectorSuites.cs b/CSVM/src/Testing/NetDirectorSuites.cs index 35a8ceb65..b6c2a7915 100644 --- a/CSVM/src/Testing/NetDirectorSuites.cs +++ b/CSVM/src/Testing/NetDirectorSuites.cs @@ -51,6 +51,7 @@ internal static class NetDirectorSuites internal static void DirectorFollow(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast().FirstOrDefault(m => string.Equals(m!.Value.ChapterFolder, Chapter, StringComparison.OrdinalIgnoreCase) diff --git a/CSVM/src/Testing/NetEnetSessionSuites.cs b/CSVM/src/Testing/NetEnetSessionSuites.cs index bca608c81..853575aad 100644 --- a/CSVM/src/Testing/NetEnetSessionSuites.cs +++ b/CSVM/src/Testing/NetEnetSessionSuites.cs @@ -25,8 +25,6 @@ namespace CSVM.Testing; /// internal static class NetEnetSessionSuites { - private const string MpMission = "MP1"; - // Loopback only. A wildcard bind is what makes Windows ask about the firewall, and a test run // must never put a dialog on anybody's screen. private const string Loopback = "127.0.0.1"; @@ -45,10 +43,6 @@ internal static class NetEnetSessionSuites // Sim steps both sessions are driven through after the join, at the fixed step. private const int LockstepSteps = 24; - // The airframe order both peers read a roster's airframe index against. Two different entries, - // so a seat's pick crossing the wire cannot be satisfied by the two ends sharing a default. - private static readonly string[] Airframes = { "player_pfighter", "player_fbrand" }; - [Suite("net-enet-join", "a host session and a guest session in one process joined over two ENet sockets on " + "127.0.0.1: the sockets link, the host's handshake and roster are held by the guest's " @@ -59,15 +53,10 @@ internal static void JoinOverEnetLoopback(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); - string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission); ctx.RequireData(SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} textures"); - ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{MpMission} zrdr"); - - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--mute", - }); + var spec = SessionSpec.Parse(new[] { "--vs" }.Concat(NetCombatSuites.Arena(ctx)) + .Concat(new[] { "--players=1", "--mute" }).ToArray()); + var airframes = NetCombatSuites.AirframesFor(spec); var hostWire = OpenHost(out int port, out string why); if (hostWire == null) @@ -99,8 +88,8 @@ internal static void JoinOverEnetLoopback(TestContext ctx) var roster = new NetSeat[] { - new() { PeerId = hostWire.LocalPeer, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = Airframes[0] }, - new() { PeerId = hostWire.Peers[0], SeatIndex = 1, Callsign = "guest", PlaneNode = Airframes[1] }, + new() { PeerId = hostWire.LocalPeer, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = airframes[0] }, + new() { PeerId = hostWire.Peers[0], SeatIndex = 1, Callsign = "guest", PlaneNode = airframes[1] }, }; NetSeats.Validate(roster); @@ -268,6 +257,7 @@ private static Ends Open(TestContext ctx, SessionSpec spec, INetTransport transp ctx.Host.AddChild(pane); var session = new GameSession(spec, new LauncherContext { + Decode = ctx.Decode, RepoRoot = ctx.RepoRoot, DataRoot = ctx.DataRoot, PlanesGamezPath = ctx.PlanesGamezPath, @@ -292,7 +282,7 @@ private static Ends Open(TestContext ctx, SessionSpec spec, INetTransport transp NetSeats = isHost ? roster : null, NetTransport = transport, NetHost = isHost, - NetAirframes = Airframes, + NetAirframes = NetCombatSuites.AirframesFor(spec), }); pane.AddChild(session); return new Ends(pane, session, session.StartSession()); diff --git a/CSVM/src/Testing/NetFlagSuites.cs b/CSVM/src/Testing/NetFlagSuites.cs index c07d2b71e..30fe68f74 100644 --- a/CSVM/src/Testing/NetFlagSuites.cs +++ b/CSVM/src/Testing/NetFlagSuites.cs @@ -5,6 +5,7 @@ using CSVM.Flight.Modes; using CSVM.Flight.Weapons; using CSVM.Launch; +using CSVM.Mech3; using CSVM.Net; using CSVM.Session; using CSVM.Session.World; @@ -49,6 +50,15 @@ internal static class NetFlagSuites private static readonly Dictionary TeamNames = new() { [1] = "Red Squadron", [2] = "Blue Angels" }; + // The rows every flag marker reads, by the keys FlagMarkers names. The side words come first, + // then each marker's line for the flag's own side and for the other. + private static readonly string[] MarkerRows = + { + FlagMarkers.YourKey, FlagMarkers.EnemyKey, FlagMarkers.CapturedByKey, FlagMarkers.HoldsFlagKey, + "MSG_MP_YOUR_BASE", "MSG_MP_ENEMY_BASE", "MSG_MP_YOUR_FLAG_BASE", "MSG_MP_ENEMY_FLAG_BASE", + "MSG_YOUR_FLAG_FLOAT", "MSG_ENEMY_FLAG_FLOAT", + }; + // Steps that outlast a cooldown, so the next ask of the same seat is not refused by it. private static int CooledSteps => (int)(FlagMatch.HomeTakeCooldown / GameClock.FixedDt) + 10; @@ -68,7 +78,7 @@ internal static void AMatchOfFlagsAcrossThreeMachines(TestContext ctx) { var spec = Spec(ctx); var mesh = LoopbackTransport.Mesh(3, LoopbackConditions.Perfect, new Random(4001)); - var roster = NetCombatSuites.Roster(3).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(3, spec).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try @@ -105,10 +115,12 @@ internal static void AMatchOfFlagsAcrossThreeMachines(TestContext ctx) Park(peers, seat); } - HomeMarkers(ctx, peers); - Capture(ctx, peers); + var rows = Messages.Load(ctx.MessagesPath); + Worded(ctx, rows); + HomeMarkers(ctx, peers, rows); + Capture(ctx, peers, rows); Lockstep(CooledSteps, peers); - CatchAndReturn(ctx, peers); + CatchAndReturn(ctx, peers, rows); Lockstep(CooledSteps, peers); ThrowRunsOut(ctx, peers); Lockstep(CooledSteps, peers); @@ -130,16 +142,12 @@ internal static void AMatchOfFlagsAcrossThreeMachines(TestContext ctx) private static SessionSpec Spec(TestContext ctx) { - string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, SessionSpec.CtfMission); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); - ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{SessionSpec.CtfMission} zrdr"); - return SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={SessionSpec.CtfMission}", "--players=1", "--mute", - "--no-pads", "--ctf", TrackedFlight, $"--vs-kills={ScoreTarget(ctx)}", - }); + return SessionSpec.Parse(new[] { "--vs" } + .Concat(NetCombatSuites.Arena(ctx, mission: SessionSpec.CtfMission)) + .Concat(new[] { "--players=1", "--mute", "--no-pads", "--ctf", TrackedFlight, $"--vs-kills={ScoreTarget(ctx)}" }) + .ToArray()); } // Two flags on every machine, at the same homes, each at its base with its carried twin hidden. @@ -160,12 +168,12 @@ private static bool Built(TestContext ctx, GameSession[] peers) } // The guest on team 2 takes team 1's flag at its base, and brings it to its own. - private static void Capture(TestContext ctx, GameSession[] peers) + private static void Capture(TestContext ctx, GameSession[] peers, Messages rows) { Put(peers, seat: 2, peers[2].Dogfight!.Flags!.Flags.HomeOf(1)); Lockstep(AskSteps, peers); Held(ctx, peers, "the team 2 guest takes team 1's flag at its base", team: 1, holder: 2); - HeldMarkers(ctx, peers); + HeldMarkers(ctx, peers, rows); var spoken = peers.Select(p => string.Join(",", p.Dogfight!.Flags!.Spoken)).ToArray(); ctx.Check(peers[0].Dogfight!.Flags!.Spoken.Contains("snd_CTFlost") && peers[1].Dogfight!.Flags!.Spoken.Contains("snd_CTFlost") && peers[2].Dogfight!.Flags!.Spoken.Contains("snd_CTFstolen") && !peers[2].Dogfight!.Flags!.Spoken.Contains("snd_CTFlost"), @@ -192,7 +200,7 @@ private static void Capture(TestContext ctx, GameSession[] peers) // The carrier goes down with the flag and it floats everywhere. The host's own pilot on team 1 // catches it and returns it. - private static void CatchAndReturn(TestContext ctx, GameSession[] peers) + private static void CatchAndReturn(TestContext ctx, GameSession[] peers, Messages rows) { Put(peers, seat: 2, peers[2].Dogfight!.Flags!.Flags.HomeOf(1)); Lockstep(AskSteps, peers); @@ -200,7 +208,7 @@ private static void CatchAndReturn(TestContext ctx, GameSession[] peers) Down(peers, victim: 2, killer: 0); ctx.Check(peers.All(p => p.Dogfight!.Flags!.Flags.RowOf(1) is { State: FlagState.Floating, Holder: FlagMatch.NoHolder }), $"the carrier's death floats the flag on every machine ({Rows(peers)})"); - FloatingMarkers(ctx, peers); + FloatingMarkers(ctx, peers, rows); // Over the flag rather than on it: a flag at rest lies against a base's buildings. int before = peers[0].Dogfight!.Match.ScoreOf(0); @@ -282,16 +290,32 @@ private static void SecondCapture(TestContext ctx, GameSession[] peers) $"and every machine's board names the capturing team ({string.Join(" | ", titles)})"); } + // Every marker line below is read off the run's own table, so a missing row would compare + // the key against itself. Each side's two lines must also differ, or no read could tell the + // sides apart. + private static void Worded(TestContext ctx, Messages rows) + { + var missing = MarkerRows.Where(key => rows.Get(key) == key).ToArray(); + var pairs = new[] { ("MSG_MP_YOUR_BASE", "MSG_MP_ENEMY_BASE"), ("MSG_MP_YOUR_FLAG_BASE", "MSG_MP_ENEMY_FLAG_BASE"), + ("MSG_YOUR_FLAG_FLOAT", "MSG_ENEMY_FLAG_FLOAT"), (FlagMarkers.YourKey, FlagMarkers.EnemyKey) }; + var alike = pairs.Where(p => rows.Get(p.Item1) == rows.Get(p.Item2)).Select(p => p.Item1).ToArray(); + ctx.Check(missing.Length == 0 && alike.Length == 0, + $"the message table words every flag marker line, each side apart (missing {string.Join(",", missing)}; alike {string.Join(",", alike)})"); + } + // Both flags home. Each base and each flag at its base reads "Your" to its own team's pane and // "Enemy" to the other's, under its team's name. No away marker stands. - private static void HomeMarkers(TestContext ctx, GameSession[] peers) + private static void HomeMarkers(TestContext ctx, GameSession[] peers, Messages rows) { Reads(ctx, peers, "every pane reads team 1's base as Your Base on team 1 and Enemy Base on team 2, " + "where the map's own table labels it Team 1 on every machine", "ctf_1", + m => $"{Side(m, 1, rows.Get("MSG_MP_YOUR_BASE"), rows.Get("MSG_MP_ENEMY_BASE"))}|Red Squadron", m => $"{Side(m, 1, "Your Base", "Enemy Base")}|Red Squadron"); Reads(ctx, peers, "and team 2's base the other way round", "ctf_2", + m => $"{Side(m, 2, rows.Get("MSG_MP_YOUR_BASE"), rows.Get("MSG_MP_ENEMY_BASE"))}|Blue Angels", m => $"{Side(m, 2, "Your Base", "Enemy Base")}|Blue Angels"); Reads(ctx, peers, "every pane reads team 1's flag at its base by side", "cs_flag_1", + m => $"{Side(m, 1, rows.Get("MSG_MP_YOUR_FLAG_BASE"), rows.Get("MSG_MP_ENEMY_FLAG_BASE"))}|Red Squadron", m => $"{Side(m, 1, "Your Flag At Base", "Enemy Flag At Base")}|Red Squadron"); Reads(ctx, peers, "and no pane has an away marker for a flag at home", "cs_flg_light1", _ => "off"); NetTeamSuites.PilotNames(ctx, peers, "with no flag taken, every pane reads every other seat's aeroplane by its callsign"); @@ -299,28 +323,40 @@ private static void HomeMarkers(TestContext ctx, GameSession[] peers) // Team 1's flag held by seat 2. Its at-base marker is off and its away marker names the carrier // by side. The carrier's own name line is its tag. - private static void HeldMarkers(TestContext ctx, GameSession[] peers) + private static void HeldMarkers(TestContext ctx, GameSession[] peers, Messages rows) { string carrier = peers[0].NetSeats[2].Callsign; + string your = rows.Get(FlagMarkers.YourKey); + string enemy = rows.Get(FlagMarkers.EnemyKey); Reads(ctx, peers, "with the flag taken no pane marks it at its base", "cs_flag_1", _ => "off"); Reads(ctx, peers, "and every pane marks it away, captured by its carrier, by side", "cs_flg_light1", + m => $"{Messages.Fill(rows.Get(FlagMarkers.CapturedByKey), Side(m, 1, your, enemy), carrier)}|Red Squadron", m => $"{Side(m, 1, "Your", "Enemy")} Flag Captured by {carrier}|Red Squadron"); Reads(ctx, peers, "ABLE-TO-FAIL CONTROL: team 2's flag still stands at its base on every pane", "cs_flag_2", + m => $"{Side(m, 2, rows.Get("MSG_MP_YOUR_FLAG_BASE"), rows.Get("MSG_MP_ENEMY_FLAG_BASE"))}|Blue Angels", m => $"{Side(m, 2, "Your Flag At Base", "Enemy Flag At Base")}|Blue Angels"); var tags = new[] { 0, 1 }.Select(m => NetTeamSuites.RefOf(NetTeamSuites.Cycles(peers[m], m), s => ReferenceEquals(s, peers[m].SeatRigs[2].Controller))?.DisplayName ?? "missing").ToArray(); - ctx.Check(tags.All(t => t == $"{carrier} Holds Your flag"), - $"and team 1's panes read the carrier's own marker as holding their flag ({string.Join(" | ", tags)})"); + string tag = Messages.Fill(rows.Get(FlagMarkers.HoldsFlagKey), carrier, your); + ctx.Check(tags.All(t => t == tag), + $"and team 1's panes read the carrier's own marker as holding their flag, in the message table's line ({string.Join(" | ", tags)})"); + if (!ctx.SyntheticData) + { + ctx.Check(tags.All(t => t == $"{carrier} Holds Your flag"), + $"and team 1's panes read the carrier's own marker as holding their flag ({string.Join(" | ", tags)})"); + } + var near = NetTeamSuites.RefOf(NetTeamSuites.Cycles(peers[0], 0), s => s is ObjectiveSite { Node: "cs_flg_light1" })?.Position.DistanceTo(peers[0].SeatRigs[2].Controller!.WorldPosition); ctx.Check(near is < 30f, $"and the away marker stands on the carrier ({near?.ToString("0.0") ?? "none"} m)"); } // The carrier down: the away marker reads floating by side and the carrier's tag is gone. - private static void FloatingMarkers(TestContext ctx, GameSession[] peers) + private static void FloatingMarkers(TestContext ctx, GameSession[] peers, Messages rows) { Reads(ctx, peers, "every pane marks the floating flag by side", "cs_flg_light1", + m => $"{Side(m, 1, rows.Get("MSG_YOUR_FLAG_FLOAT"), rows.Get("MSG_ENEMY_FLAG_FLOAT"))}|Red Squadron", m => $"{Side(m, 1, "Your Flag Floating", "Enemy Flag Floating")}|Red Squadron"); ctx.Check(peers.All(p => p.SeatRigs[2].Controller!.MarkerName == null), $"and no machine still tags the downed carrier"); @@ -330,13 +366,27 @@ private static void FloatingMarkers(TestContext ctx, GameSession[] peers) private static string Side(int machine, int team, string your, string enemy) => Teams[machine] == team ? your : enemy; - private static void Reads(TestContext ctx, GameSession[] peers, string what, string key, Func want) + // A marker read on every pane against the lines the run's table words. On a real extraction it + // is also read against the shipped wording, `shipped`, which an invented table does not carry. + private static void Reads(TestContext ctx, GameSession[] peers, string what, string key, Func want, + Func? shipped = null) { var got = peers.Select((p, m) => NetTeamSuites.RefOf(NetTeamSuites.Cycles(p, m), s => s is ObjectiveSite site && site.Node.Equals(key, StringComparison.OrdinalIgnoreCase)) is { } t ? $"{t.Category}|{t.DisplayName}" : "off").ToArray(); - ctx.Check(got.Select((g, m) => g == want(m)).All(ok => ok), $"{what} ({string.Join(" | ", got)})"); + string reading = string.Join(" | ", got); + if (shipped == null) + { + ctx.Check(got.Select((g, m) => g == want(m)).All(ok => ok), $"{what} ({reading})"); + return; + } + + ctx.Check(got.Select((g, m) => g == want(m)).All(ok => ok), $"{what}, in the message table's lines ({reading})"); + if (!ctx.SyntheticData) + { + ctx.Check(got.Select((g, m) => g == shipped(m)).All(ok => ok), $"{what} ({reading})"); + } } private static void Held(TestContext ctx, GameSession[] peers, string what, int team, int holder) diff --git a/CSVM/src/Testing/NetLobbyEnvironmentSuites.cs b/CSVM/src/Testing/NetLobbyEnvironmentSuites.cs index 99474029b..1dadf7bca 100644 --- a/CSVM/src/Testing/NetLobbyEnvironmentSuites.cs +++ b/CSVM/src/Testing/NetLobbyEnvironmentSuites.cs @@ -69,7 +69,7 @@ internal static void AboveTheCloudsFliesC1C(TestContext ctx) var starts = Zeppelins.Load(missionZrdr).ToDictionary(z => z.Node, z => z.Position, StringComparer.OrdinalIgnoreCase); var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(9101)); - var roster = NetCombatSuites.Roster(2).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(2, spec).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try @@ -181,7 +181,7 @@ internal static void AStuntRaceFliesTheChaptersCourse(TestContext ctx) ctx.RequireData(missionZrdr, $"{RaceChapter}/{SessionSpec.StuntRaceMission} zrdr"); var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(9102)); - var roster = NetCombatSuites.Roster(2); + var roster = NetCombatSuites.Roster(2, specs[0]); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try diff --git a/CSVM/src/Testing/NetPositionalStartSuites.cs b/CSVM/src/Testing/NetPositionalStartSuites.cs index 9a78d0f07..4233c4cc7 100644 --- a/CSVM/src/Testing/NetPositionalStartSuites.cs +++ b/CSVM/src/Testing/NetPositionalStartSuites.cs @@ -80,6 +80,7 @@ internal static class NetPositionalStartSuites internal static void PositionalStart(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = CampaignSequence.Load(ctx.ZrdrPath).Cast() .FirstOrDefault(m => m!.Value.Seq == Cm02Seq) diff --git a/CSVM/src/Testing/NetRearmSuites.cs b/CSVM/src/Testing/NetRearmSuites.cs index 92c03a24b..7f54c1458 100644 --- a/CSVM/src/Testing/NetRearmSuites.cs +++ b/CSVM/src/Testing/NetRearmSuites.cs @@ -3,6 +3,7 @@ using System.Linq; using CSVM.Extraction; using CSVM.Flight.Airframe; +using CSVM.Flight.Hud; using CSVM.Flight.Modes; using CSVM.Flight.Weapons; using CSVM.Launch; @@ -249,7 +250,7 @@ private static void RunPair(TestContext ctx, SessionSpec spec, int[] teams, int SessionSpec? guestSpec = null, Action? opened = null) { var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(meshSeed)); - var roster = NetCombatSuites.Roster(2).Select((seat, i) => seat with { TeamId = teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(2, spec).Select((seat, i) => seat with { TeamId = teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try @@ -335,8 +336,15 @@ private static void Restored(TestContext ctx, GameSession[] peers, FlightControl $"and keeps its selected pylon rather than the first ({guest.SelectedPylon}, selected {pylon})"); string? own = guest.MessageStack?.LineAt(0); string? hosts = peers[0].SeatRigs[0].Controller!.MessageStack?.LineAt(0); - ctx.Check(own == Rearmed && hosts != Rearmed, - $"its own pane posts \"{Rearmed}\" and the host's pane does not ('{own}' / '{hosts}')"); + // Read off the run's own table; a missing row reads as its key, which the pane would match. + string rearmed = Messages.Load(ctx.MessagesPath).Get(HudMessages.RearmedKey); + ctx.Check(rearmed != HudMessages.RearmedKey && own == rearmed && hosts != rearmed, + $"its own pane posts the message table's \"{rearmed}\" and the host's pane does not ('{own}' / '{hosts}')"); + if (!ctx.SyntheticData) + { + ctx.Check(own == Rearmed && hosts != Rearmed, + $"its own pane posts \"{Rearmed}\" and the host's pane does not ('{own}' / '{hosts}')"); + } } // Damaged again while still inside the radius, the latch holds and nothing is restored. diff --git a/CSVM/src/Testing/NetSeatSuites.cs b/CSVM/src/Testing/NetSeatSuites.cs index 9bae7cde7..274164203 100644 --- a/CSVM/src/Testing/NetSeatSuites.cs +++ b/CSVM/src/Testing/NetSeatSuites.cs @@ -64,17 +64,14 @@ internal static void RemoteSeatsWithoutPanes(TestContext ctx) string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission); ctx.RequireData(texturesPath, $"{ctx.Chapter} textures"); ctx.RequireData(chapterZrdr, $"{ctx.Chapter} zrdr"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{MpMission} zrdr"); - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--spawn=0", - }); + var spec = SessionSpec.Parse(new[] { "--vs" }.Concat(NetCombatSuites.Arena(ctx, gateChapterGamez: false)) + .Concat(new[] { "--players=1", "--spawn=0" }).ToArray()); var picker = new SpawnPicker(spec); var table = picker.LoadSpawnList(missionZrdr, spec.Scenario); if (table is not { Count: >= 4 }) { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); + throw new SuiteSkippedException($"{NetCombatSuites.ArenaTable(ctx, spec)} holds no usable spawn table"); } // Seat 0 is this machine's pane; seats 1 and 2 are flown elsewhere. Seat 1 names its own @@ -223,17 +220,14 @@ internal static void GuestKeymapIsLocal(TestContext ctx) string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission); ctx.RequireData(texturesPath, $"{ctx.Chapter} textures"); ctx.RequireData(chapterZrdr, $"{ctx.Chapter} zrdr"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{MpMission} zrdr"); - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--spawn=0", - }); + var spec = SessionSpec.Parse(new[] { "--vs" }.Concat(NetCombatSuites.Arena(ctx, gateChapterGamez: false)) + .Concat(new[] { "--players=1", "--spawn=0" }).ToArray()); var picker = new SpawnPicker(spec); var table = picker.LoadSpawnList(missionZrdr, spec.Scenario); if (table is not { Count: >= 3 }) { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); + throw new SuiteSkippedException($"{NetCombatSuites.ArenaTable(ctx, spec)} holds no usable spawn table"); } // The guest's side of a match: the host flies seat 0 elsewhere, and this machine's two diff --git a/CSVM/src/Testing/NetSessionSuites.cs b/CSVM/src/Testing/NetSessionSuites.cs index 6e7e5d567..3a1f5c9e3 100644 --- a/CSVM/src/Testing/NetSessionSuites.cs +++ b/CSVM/src/Testing/NetSessionSuites.cs @@ -24,8 +24,6 @@ namespace CSVM.Testing; /// , so neither one's hulls, lights or areas can reach the other's. internal static class NetSessionSuites { - private const string MpMission = "MP1"; - // What the host and the guest are launched with. Different on purpose: the assertion that the // handshake replaced the guest's seed cannot then be satisfied by a shared launch value. private const ulong HostSeed = 0xA5A50101UL; @@ -44,6 +42,10 @@ internal static class NetSessionSuites // for each step itself, so the reading is in sim time and never in wall time. private const int FlightSteps = 240; + // The same flight on the empty arena. Its invented airframes bend that stick through less of a + // curve than the shipped pair. The leg so runs longer to clear the same 30 degree floor. + private const int ArenaFlightSteps = 360; + // The alignment search's width, in sim steps, and the index the measurement starts at. It has // to cover the buffer delay plus the link's latency and jitter. Starting there also skips the // opening steps, where the shown aeroplane holds its spawn because nothing has arrived. @@ -59,10 +61,6 @@ internal static class NetSessionSuites private const float MeanErrorBar = 1.5f; private const float WorstErrorBar = 5f; - // The airframe order both peers read a roster's airframe index against. Two different entries, - // so a seat's pick crossing the wire cannot be satisfied by the two ends sharing a default. - private static readonly string[] Airframes = { "player_pfighter", "player_fbrand" }; - [Suite("net-two-session", "a host session and a guest session in one process, joined over a two-transport loopback " + "mesh and stepped in lockstep: the guest takes the host's seed off the handshake in place " @@ -72,24 +70,17 @@ internal static class NetSessionSuites + "receiving session's own step, and the two worlds stand in separate physics spaces")] internal static void TwoSessionsOverOneLoopback(TestContext ctx) { - string missionZrdr = RequireMatchData(ctx); - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--mute", - }); - var table = new SpawnPicker(spec).LoadSpawnList(missionZrdr, spec.Scenario); - if (table is not { Count: >= 2 }) - { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); - } + var spec = NetCombatSuites.MatchSpec(ctx, out var table); + var airframes = NetCombatSuites.DistinctAirframesFor(spec); + ctx.RequirePlane(airframes); // A lossy, jittery link on purpose. The join and everything this suite asserts on is // reliable traffic, which the transport must carry in order whatever the conditions. var mesh = LoopbackTransport.Mesh(2, new LoopbackConditions(0.03, 0.01, 0.25), new Random(6571)); var roster = new NetSeat[] { - new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = Airframes[0] }, - new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = Airframes[1] }, + new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = airframes[0] }, + new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = airframes[1] }, }; NetSeats.Validate(roster); @@ -105,11 +96,11 @@ internal static void TwoSessionsOverOneLoopback(TestContext ctx) { long memBefore = (long)OS.GetStaticMemoryUsage(); var wall = Stopwatch.StartNew(); - host = Open(ctx, spec, mesh[0], isHost: true, HostSeed, roster); + host = Open(ctx, spec, mesh[0], isHost: true, HostSeed, roster, airframes); double hostMs = wall.Elapsed.TotalMilliseconds; long memHost = (long)OS.GetStaticMemoryUsage(); wall.Restart(); - guest = Open(ctx, spec, mesh[1], isHost: false, GuestSeed, null); + guest = Open(ctx, spec, mesh[1], isHost: false, GuestSeed, null, airframes); double guestMs = wall.Elapsed.TotalMilliseconds; long memGuest = (long)OS.GetStaticMemoryUsage(); @@ -121,7 +112,7 @@ internal static void TwoSessionsOverOneLoopback(TestContext ctx) } Join(ctx, host.Session, guest.Session); - Spawns(ctx, host.Session, guest.Session, table); + Spawns(ctx, host.Session, guest.Session, table, airframes); Traffic(ctx, host.Session, guest.Session); ctx.Check(host.Pane.World3D.Space != guest.Pane.World3D.Space && host.Session.GetWorld3D().Space != guest.Session.GetWorld3D().Space, @@ -151,25 +142,17 @@ internal static void TwoSessionsOverOneLoopback(TestContext ctx) + "aeroplane's path is an order of magnitude worse")] internal static void AircraftStateTracksItsOwner(TestContext ctx) { - string missionZrdr = RequireMatchData(ctx); - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--mute", - "--no-pads", TrackedFlight, - }); - var table = new SpawnPicker(spec).LoadSpawnList(missionZrdr, spec.Scenario); - if (table is not { Count: >= 2 }) - { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); - } + var spec = NetCombatSuites.MatchSpec(ctx, out _, TrackedFlight); + var airframes = NetCombatSuites.DistinctAirframesFor(spec); + ctx.RequirePlane(airframes); // The same link the join is asserted over. Aircraft state is the unreliable sequenced // class, so a quarter of these samples never land and the buffer covers the gaps. var mesh = LoopbackTransport.Mesh(2, new LoopbackConditions(0.03, 0.01, 0.25), new Random(9311)); var roster = new NetSeat[] { - new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = Airframes[0] }, - new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = Airframes[1] }, + new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = airframes[0] }, + new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = airframes[1] }, }; NetSeats.Validate(roster); @@ -181,8 +164,8 @@ internal static void AircraftStateTracksItsOwner(TestContext ctx) Ends? guest = null; try { - host = Open(ctx, spec, mesh[0], isHost: true, HostSeed, roster); - guest = Open(ctx, spec, mesh[1], isHost: false, GuestSeed, null); + host = Open(ctx, spec, mesh[0], isHost: true, HostSeed, roster, airframes); + guest = Open(ctx, spec, mesh[1], isHost: false, GuestSeed, null, airframes); ctx.Check(host.Built && guest.Built, $"both sessions build in one process (host {host.Built}, guest {guest.Built})"); if (!host.Built || !guest.Built) @@ -190,7 +173,7 @@ internal static void AircraftStateTracksItsOwner(TestContext ctx) return; } - var flight = Fly(host.Session, guest.Session); + var flight = Fly(host.Session, guest.Session, spec.EmptyStage ? ArenaFlightSteps : FlightSteps); Tracking(ctx, flight); } finally @@ -211,23 +194,14 @@ internal static void AircraftStateTracksItsOwner(TestContext ctx) + "under its pause, and switches its graphics mode live and back")] internal static void PauseSheetLeavesANetworkFlightRunning(TestContext ctx) { - string missionZrdr = RequireMatchData(ctx); - var spec = SessionSpec.Parse(new[] - { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=1", "--mute", - "--no-pads", TrackedFlight, - }); - var table = new SpawnPicker(spec).LoadSpawnList(missionZrdr, spec.Scenario); - if (table is not { Count: >= 2 }) - { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no usable net.zrd table"); - } + var spec = NetCombatSuites.MatchSpec(ctx, out _, TrackedFlight); + var airframes = NetCombatSuites.AirframesFor(spec); var mesh = LoopbackTransport.Mesh(2, new LoopbackConditions(0.03, 0.01, 0.25), new Random(4127)); var roster = new NetSeat[] { - new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = Airframes[0] }, - new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = Airframes[1] }, + new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = airframes[0] }, + new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = airframes[1] }, }; NetSeats.Validate(roster); @@ -322,10 +296,10 @@ internal static (float Lag, float Mean, float Max) Track( // One tracked flight: both sessions stepped together, with the four paths that matter // recorded after every step. The case the guest's buffer answered from is counted // beside them. - private static TrackedRun Fly(GameSession host, GameSession guest) + private static TrackedRun Fly(GameSession host, GameSession guest, int steps) { var flight = new TrackedRun(); - for (int i = 0; i < FlightSteps; i++) + for (int i = 0; i < steps; i++) { host._PhysicsProcess(GameClock.FixedDt); guest._PhysicsProcess(GameClock.FixedDt); @@ -519,18 +493,6 @@ private static float Turn(IReadOnlyList path) return turned; } - // The five data files both ends of a match are built from. - private static string RequireMatchData(TestContext ctx) - { - ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); - ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); - string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission); - ctx.RequireData(SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} textures"); - ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{MpMission} zrdr"); - return missionZrdr; - } - // The seed, the seat and the roster a guest is built from are the host's, and nothing of its // own launch survives the join. The local flags are the one thing that must differ. private static void Join(TestContext ctx, GameSession host, GameSession guest) @@ -557,7 +519,7 @@ private static void Join(TestContext ctx, GameSession host, GameSession guest) // The spawn walk is the real proof the two peers agree. With no --spawn the base is drawn // from the seeded Spawn stream, so a disagreed seed moves a seat to another table entry. private static void Spawns(TestContext ctx, GameSession host, GameSession guest, - IReadOnlyList table) + IReadOnlyList table, IReadOnlyList airframes) { ctx.Same(host.SeatRigs.Count, guest.SeatRigs.Count, $"both worlds size themselves by the field"); var mine = host.SeatRigs.Select(r => EntryAt(table, r.Controller)).ToArray(); @@ -567,7 +529,7 @@ private static void Spawns(TestContext ctx, GameSession host, GameSession guest, ctx.Check(new HashSet(mine).Count == mine.Length, $"and no two seats share one (entries {string.Join(", ", mine)})"); string planes = string.Join(", ", guest.NetSeats.Select(s => s.PlaneNode)); - ctx.Check(guest.NetSeats.Select(s => s.PlaneNode).SequenceEqual(Airframes), + ctx.Check(airframes.Distinct().Count() == 2 && guest.NetSeats.Select(s => s.PlaneNode).SequenceEqual(airframes), $"and each entry's airframe index resolved back to its own name on the guest ({planes})"); } @@ -623,7 +585,7 @@ private static void Lockstep(GameSession host, GameSession guest) // One end of the match: its own pane, its own world, its own session node. The pane renders // nothing, the suite reads poses and counters rather than pixels. private static Ends Open(TestContext ctx, SessionSpec spec, INetTransport? transport, - bool isHost, ulong seed, IReadOnlyList? roster) + bool isHost, ulong seed, IReadOnlyList? roster, IReadOnlyList? airframes = null) { var pane = new SubViewport { @@ -639,6 +601,7 @@ private static Ends Open(TestContext ctx, SessionSpec spec, INetTransport? trans ctx.Host.AddChild(pane); var session = new GameSession(spec, new LauncherContext { + Decode = ctx.Decode, RepoRoot = ctx.RepoRoot, DataRoot = ctx.DataRoot, PlanesGamezPath = ctx.PlanesGamezPath, @@ -663,7 +626,7 @@ private static Ends Open(TestContext ctx, SessionSpec spec, INetTransport? trans NetSeats = isHost ? roster : null, NetTransport = transport, NetHost = isHost, - NetAirframes = transport == null ? null : Airframes, + NetAirframes = transport == null ? null : airframes ?? NetCombatSuites.AirframesFor(spec), }); pane.AddChild(session); return new Ends(pane, session, session.StartSession()); diff --git a/CSVM/src/Testing/NetSoakSuites.cs b/CSVM/src/Testing/NetSoakSuites.cs index e0ab79244..3d58ae411 100644 --- a/CSVM/src/Testing/NetSoakSuites.cs +++ b/CSVM/src/Testing/NetSoakSuites.cs @@ -61,8 +61,6 @@ internal static class NetSoakSuites // behind a delivered payload, so the inferred count can meet the carrier's. private const int FlushFireSteps = 12; - private static readonly string[] Airframes = { "player_pfighter", "player_fbrand" }; - // The matrix. The clean cell is the instruments' own control. The other three step latency, // jitter and loss up together, from a good broadband link to a poor wireless one. private static readonly Cell[] Matrix = @@ -85,6 +83,7 @@ internal static class NetSoakSuites internal static void SoakTheLink(TestContext ctx) { var spec = SoakSpec(ctx); + var airframes = NetCombatSuites.AirframesFor(spec); var weapons = WeaponDefs.Load(ctx.ZrdrPath); var gun = weapons.All.FirstOrDefault(w => w.IsCannon && w.HealthDamage is > 0f); if (gun == null) @@ -95,8 +94,8 @@ internal static void SoakTheLink(TestContext ctx) var mesh = LoopbackTransport.Mesh(2, LoopbackConditions.Perfect, new Random(MeshSeed)); var roster = new NetSeat[] { - new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = Airframes[0] }, - new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = Airframes[1] }, + new() { PeerId = 0, SeatIndex = 0, FlownHere = true, Callsign = "host", PlaneNode = airframes[0] }, + new() { PeerId = 1, SeatIndex = 1, Callsign = "guest", PlaneNode = airframes[1] }, }; NetSeats.Validate(roster); @@ -336,17 +335,13 @@ private static SessionSpec SoakSpec(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); - string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, "MP1"); ctx.RequireData(SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} textures"); - ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, ctx.Chapter), $"{ctx.Chapter} gamez"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/MP1 zrdr"); // Both limits off, so no number of kills or minutes ends the match mid-matrix and holds // every world at the wrap-up board. - return SessionSpec.Parse(new[] + return SessionSpec.Parse(new[] { "--vs" }.Concat(NetCombatSuites.Arena(ctx)).Concat(new[] { - "--vs", $"--chapter={ctx.Chapter}", "--mission=MP1", "--players=1", "--mute", - "--no-pads", TrackedFlight, "--vs-kills=0", "--vs-time=0", - }); + "--players=1", "--mute", "--no-pads", TrackedFlight, "--vs-kills=0", "--vs-time=0", + }).ToArray()); } private static int Rounds(FlightController gun) => diff --git a/CSVM/src/Testing/NetStartSuites.cs b/CSVM/src/Testing/NetStartSuites.cs index b4e7a3dbf..cc28ff57e 100644 --- a/CSVM/src/Testing/NetStartSuites.cs +++ b/CSVM/src/Testing/NetStartSuites.cs @@ -72,7 +72,7 @@ private static void SlowGuest(TestContext ctx, SessionSpec spec) NetCombatSuites.Ends? guest = null; try { - host = NetCombatSuites.Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, NetCombatSuites.Roster(2)); + host = NetCombatSuites.Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, NetCombatSuites.Roster(2, spec)); ctx.Check(host.Built, $"[slow guest] the host builds ({host.Built})"); if (!host.Built || Own(host.Session) is not { } hostPlane || host.Session.Dogfight?.Match is not { } match) { @@ -127,7 +127,7 @@ private static void DroppedWhileLoading(TestContext ctx, SessionSpec spec) NetCombatSuites.Ends? first = null; try { - host = NetCombatSuites.Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, NetCombatSuites.Roster(3)); + host = NetCombatSuites.Ends.Open(ctx, spec, mesh[0], isHost: true, HostSeed, NetCombatSuites.Roster(3, spec)); first = NetCombatSuites.Ends.Open(ctx, spec, mesh[1], isHost: false, HostSeed + 1, null); ctx.Check(host.Built && first.Built, $"[dropped] the host and the first guest build ({host.Built}, {first.Built})"); if (!host.Built || !first.Built || host.Session.Dogfight?.Match is not { } match diff --git a/CSVM/src/Testing/NetStuntRaceSuites.cs b/CSVM/src/Testing/NetStuntRaceSuites.cs index 143594503..32fb36903 100644 --- a/CSVM/src/Testing/NetStuntRaceSuites.cs +++ b/CSVM/src/Testing/NetStuntRaceSuites.cs @@ -239,7 +239,7 @@ private static void Race(TestContext ctx, string cell, LoopbackConditions link, vsLives: rules.Lives, vsAutoRespawn: rules.AutoRespawn, missionType: rules.MissionType); var mesh = LoopbackTransport.Mesh(2, link, new Random(seed)); - var roster = NetCombatSuites.Roster(2); + var roster = NetCombatSuites.Roster(2, spec); var ends = new List(); try { @@ -499,7 +499,7 @@ private static void EndLobby(TestContext ctx) new[] { "player_pfighter" }, DogfightLobby.LaunchMode(options), vsTimeMinutes: rules.TimeLimitMinutes, vsLives: rules.Lives, vsAutoRespawn: rules.AutoRespawn, missionType: rules.MissionType); var mesh = LoopbackTransport.Mesh(2, new LoopbackConditions(Latency, 0.0, 0.0), new Random(seed)); - var roster = NetCombatSuites.Roster(2); + var roster = NetCombatSuites.Roster(2, spec); var ends = new List(); for (int i = 0; i < 2; i++) { diff --git a/CSVM/src/Testing/NetTeamSuites.cs b/CSVM/src/Testing/NetTeamSuites.cs index 62f858b7c..07993e86c 100644 --- a/CSVM/src/Testing/NetTeamSuites.cs +++ b/CSVM/src/Testing/NetTeamSuites.cs @@ -9,6 +9,7 @@ using CSVM.Launch; using CSVM.Net; using CSVM.Session; +using CSVM.Session.Roster; using CSVM.UI.Screens; using CSVM.Utils; using Godot; @@ -54,14 +55,17 @@ internal static class NetTeamSuites internal static void ATeamMatchAcrossThreeMachines(TestContext ctx) { var spec = NetCombatSuites.MatchSpec(ctx, out _, TrackedFlight, $"--vs-kills={TeamTarget(MatchScores.Load(ctx.ZrdrPath))}"); - var table = SpawnPoints.LoadNetTable(SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission)); + // The whole table the sessions' own pickers read for these teams: MP1's net.zrd, or the + // empty stage's team blocks. + var table = new SpawnPicker(spec) { SeatTeams = Teams } + .LoadSpawnList(SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission), spec.Scenario); if (table is not { Count: > 2 * SpawnPoints.NetBlock }) { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission}'s net.zrd authors no second team block"); + throw new SuiteSkippedException($"{NetCombatSuites.ArenaTable(ctx, spec)} authors no second team block"); } var mesh = LoopbackTransport.Mesh(3, LoopbackConditions.Perfect, new Random(8401)); - var roster = NetCombatSuites.Roster(3).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(3, spec).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); try diff --git a/CSVM/src/Testing/NetWorldSuites.cs b/CSVM/src/Testing/NetWorldSuites.cs index 30dc5289c..be5dabc93 100644 --- a/CSVM/src/Testing/NetWorldSuites.cs +++ b/CSVM/src/Testing/NetWorldSuites.cs @@ -634,6 +634,7 @@ public static Ends Open(TestContext ctx, SessionSpec spec, INetTransport transpo ctx.Host.AddChild(pane); var session = new GameSession(spec, new LauncherContext { + Decode = ctx.Decode, RepoRoot = ctx.RepoRoot, DataRoot = ctx.DataRoot, PlanesGamezPath = ctx.PlanesGamezPath, diff --git a/CSVM/src/Testing/NetZeppelinVersusSuites.cs b/CSVM/src/Testing/NetZeppelinVersusSuites.cs index cbd605db2..a2d9d64dd 100644 --- a/CSVM/src/Testing/NetZeppelinVersusSuites.cs +++ b/CSVM/src/Testing/NetZeppelinVersusSuites.cs @@ -76,7 +76,7 @@ internal static class NetZeppelinVersusSuites internal static void AMatchOfHullsAcrossThreeMachines(TestContext ctx) { var spec = Spec(ctx); - var roster = NetCombatSuites.Roster(3).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); + var roster = NetCombatSuites.Roster(3, spec).Select((seat, i) => seat with { TeamId = Teams[i] }).ToArray(); var ambient = NetCombatSuites.Ambient.Save(); var ends = new List(); var exits = new int[3]; diff --git a/CSVM/src/Testing/OrdnanceSuites.cs b/CSVM/src/Testing/OrdnanceSuites.cs index 1b6adbf65..37e8f0aa1 100644 --- a/CSVM/src/Testing/OrdnanceSuites.cs +++ b/CSVM/src/Testing/OrdnanceSuites.cs @@ -30,6 +30,24 @@ internal static class OrdnanceSuites // actually strikes. C1's buildings are its airport hangars, a different surface entirely. private const string FilmLotChapter = "C2"; + /// The weapon a suite flies. On a real extraction it is the shipped id the suite's + /// literal checks name. On the synthetic tree it is the first record that + /// the behaviour under test, an invented wep_probe_* entry + /// whose values CSVM.Tests/fixtures/README.md states. + internal static WeaponDef? PickWeapon(TestContext ctx, WeaponDefs weapons, string shippedId, + System.Func carries) => + ctx.SyntheticData ? weapons.All.FirstOrDefault(carries) : weapons.Get(shippedId); + + /// The HE rocket a burst suite drops, wep_06 on a real extraction: a + /// high-explosive record whose default row plays a fireball the catalogue lights. + internal static WeaponDef? PickFireballRocket(TestContext ctx, WeaponDefs weapons) => + PickWeapon(ctx, weapons, "wep_06", + w => w.HighExplosive && DefaultBurst(w) is { } fx && EffectCatalogue.IsBurstLight(fx)); + + /// The effect a weapon's default row plays, the name a burst hands the effect sink. + internal static string? DefaultBurst(WeaponDef weapon) => + weapon.ImpactFor(SurfaceRegistry.Default) is { } row ? row.Animation ?? row.SurfaceAnimation : null; + // C10/C11 on controlled geometry: a torpedo fired straight down onto a ground plate, so the burst // sits at a known point on a known face and every target's near face is a measured distance // away. Boxes stand on the plate with a bottom edge at the exact range, so the nearest-shape @@ -48,7 +66,7 @@ internal static void BlastCurveCoverCap(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_14", out var torpedo) + if (PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo || torpedo.HealthDamage is not > 0f || torpedo.ImpactProximity is not > 0f) { ctx.Check(false, $"torpedo (wep_14) carries HEALTH_DAMAGE + IMPACT_PROXIMITY"); @@ -56,8 +74,14 @@ internal static void BlastCurveCoverCap(TestContext ctx) } float full = torpedo.HealthDamage!.Value; float radius = torpedo.ImpactProximity!.Value; - ctx.Check(Mathf.IsEqualApprox(full, 200f) && Mathf.IsEqualApprox(radius, 30f), - $"wep_14 authors HEALTH_DAMAGE 200 and IMPACT_PROXIMITY 30 (the data this lab is scaled to)"); + // The lab's farthest bodies stand 23.5 m out, so a smaller radius cannot show the curve or the cap. + ctx.Check(radius > 24f, + $"{torpedo.Id} authors IMPACT_PROXIMITY {radius:0.#}, wider than the lab's 23.5 m layout"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(full, 200f) && Mathf.IsEqualApprox(radius, 30f), + $"wep_14 authors HEALTH_DAMAGE 200 and IMPACT_PROXIMITY 30 (the data this lab is scaled to)"); + } var textures = new TextureArchive(texturesPath); ProjectilePool? pool = null; @@ -208,16 +232,23 @@ internal static void LaunchVelocityDecay(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_14", out var torpedo) || !weapons.TryGet("wep_12", out var choker) - || !weapons.TryGet("wep_00", out var gun)) + if (PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo + || PickWeapon(ctx, weapons, "wep_12", w => w.Tangler != null) is not { } choker + || PickWeapon(ctx, weapons, "wep_00", w => w.IsGun) is not { } gun) { ctx.Check(false, $"wep_14, wep_12 and wep_00 all resolve"); return; } float cruise = torpedo.Velocity ?? 0f; float window = torpedo.LockOn ?? 0f; - ctx.Check(Mathf.IsEqualApprox(cruise, 60f) && Mathf.IsEqualApprox(window, 2.5f), - $"wep_14 authors VELOCITY {cruise:0.#} and LOCK_ON {window:0.##}, the decode's 60 m/s over 2.5 s"); + // Every reading below is taken against these two, so a window under the 4 s flight is all it needs. + ctx.Check(cruise > 0f && window is > 0f and < 4f && (torpedo.Acceleration ?? 0f) == 0f, + $"{torpedo.Id} authors VELOCITY {cruise:0.#} and LOCK_ON {window:0.##} and no motor"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(cruise, 60f) && Mathf.IsEqualApprox(window, 2.5f), + $"wep_14 authors VELOCITY {cruise:0.#} and LOCK_ON {window:0.##}, the decode's 60 m/s over 2.5 s"); + } ctx.Check(ProjectilePool.CarriesLockOn(torpedo) && !ProjectilePool.CarriesLockOn(choker) && !ProjectilePool.CarriesLockOn(gun), $"the decay window flag is LOCK_ON itself: wep_14 carries it, the choker and the gun do not"); @@ -254,30 +285,30 @@ float SpeedAt(WeaponDef weapon, float launchSpeed, object? held, float seconds) // The decode's own worked example: a launcher at 120 m/s over the torpedo's 2.5 s. float fast0 = SpeedAt(torpedo, 120f, target, 0f); - float fastHalf = SpeedAt(torpedo, 120f, target, 1.25f); - float fastEnd = SpeedAt(torpedo, 120f, target, 2.5f); + float fastHalf = SpeedAt(torpedo, 120f, target, window / 2f); + float fastEnd = SpeedAt(torpedo, 120f, target, window); float fastLate = SpeedAt(torpedo, 120f, target, 4f); - ctx.Check(Mathf.Abs(fast0 - 180f) < 0.5f, - $"a torpedo launched at 120 m/s leaves at launcher + VELOCITY speed={fast0:0.##} expected=180"); - ctx.Check(Mathf.Abs(fastHalf - 120f) < 0.5f, - $"half a LOCK_ON window in, half the inherited velocity is left speed={fastHalf:0.##} expected=120"); - ctx.Check(Mathf.Abs(fastEnd - 60f) < 0.5f, - $"at LOCK_ON the round is down to its authored VELOCITY speed={fastEnd:0.##} expected=60"); - ctx.Check(Mathf.Abs(fastLate - 60f) < 0.5f, - $"and it HOLDS there rather than continuing to fall speed={fastLate:0.##} expected=60"); + ctx.Check(Mathf.Abs(fast0 - (cruise + 120f)) < 0.5f, + $"a torpedo launched at 120 m/s leaves at launcher + VELOCITY speed={fast0:0.##} expected={cruise + 120f:0.##}"); + ctx.Check(Mathf.Abs(fastHalf - (cruise + 60f)) < 0.5f, + $"half a LOCK_ON window in, half the inherited velocity is left speed={fastHalf:0.##} expected={cruise + 60f:0.##}"); + ctx.Check(Mathf.Abs(fastEnd - cruise) < 0.5f, + $"at LOCK_ON the round is down to its authored VELOCITY speed={fastEnd:0.##} expected={cruise:0.##}"); + ctx.Check(Mathf.Abs(fastLate - cruise) < 0.5f, + $"and it HOLDS there rather than continuing to fall speed={fastLate:0.##} expected={cruise:0.##}"); // The other half of the goal: a slow launch has almost nothing to shed. float slow0 = SpeedAt(torpedo, 10f, target, 0f); - float slowEnd = SpeedAt(torpedo, 10f, target, 2.5f); - ctx.Check(Mathf.Abs(slow0 - 70f) < 0.5f && Mathf.Abs(slowEnd - 60f) < 0.5f, + float slowEnd = SpeedAt(torpedo, 10f, target, window); + ctx.Check(Mathf.Abs(slow0 - (cruise + 10f)) < 0.5f && Mathf.Abs(slowEnd - cruise) < 0.5f, $"a torpedo launched at 10 m/s barely slows at all launch={slow0:0.##} settled={slowEnd:0.##}"); // ⚠ The recorded divergence: the original's decay lives inside its target-gated // steering step, ours runs for every LOCK_ON round, so a torpedo fired with nothing // selected still settles onto its authored 60 rather than flying at 180 forever. float untargeted = SpeedAt(torpedo, 120f, null, 4f); - ctx.Check(Mathf.Abs(untargeted - 60f) < 0.5f, - $"a LOCK_ON round holding NO target still sheds its launcher's velocity speed={untargeted:0.##} expected=60"); + ctx.Check(Mathf.Abs(untargeted - cruise) < 0.5f, + $"a LOCK_ON round holding NO target still sheds its launcher's velocity speed={untargeted:0.##} expected={cruise:0.##}"); // The choker authors no ACCELERATION, so the original seeds it like a gun and never // rebuilds it. LOCK_ON gates the MOTOR round's inheritance, not every round's. @@ -315,16 +346,24 @@ internal static void MotorAcceleration(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_04", out var incendiary) || !weapons.TryGet("wep_26", out var fake) - || !weapons.TryGet("wep_12", out var choker) || !weapons.TryGet("wep_00", out var gun)) + if (PickWeapon(ctx, weapons, "wep_04", w => w.Acceleration is > 0f && !ProjectilePool.CarriesLockOn(w)) is not { } incendiary + || PickWeapon(ctx, weapons, "wep_26", w => w.Acceleration is > 0f && !ProjectilePool.CarriesLockOn(w)) is not { } fake + || PickWeapon(ctx, weapons, "wep_12", w => w.IsRocket && (w.Acceleration ?? 0f) == 0f && !ProjectilePool.CarriesLockOn(w)) is not { } choker + || PickWeapon(ctx, weapons, "wep_00", w => w.IsGun) is not { } gun) { ctx.Check(false, $"wep_04, wep_26, wep_12 and wep_00 all resolve"); return; } float motor = incendiary.Acceleration ?? 0f; float cruise = incendiary.Velocity ?? 0f; - ctx.Check(Mathf.IsEqualApprox(motor, 150f) && Mathf.IsEqualApprox(cruise, 450f), - $"wep_04 authors ACCELERATION {motor:0.#} m/s² and VELOCITY {cruise:0.#} m/s"); + // The 1 s and 2 s samples read the climb only while the cap is past 2 s and reached by 3 s. + ctx.Check(motor > 0f && cruise > 2f * motor && cruise <= 3f * motor, + $"{incendiary.Id} authors ACCELERATION {motor:0.#} m/s² and VELOCITY {cruise:0.#} m/s, a cap the motor reaches between 2 s and 3 s"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(motor, 150f) && Mathf.IsEqualApprox(cruise, 450f), + $"wep_04 authors ACCELERATION {motor:0.#} m/s² and VELOCITY {cruise:0.#} m/s"); + } ctx.Check((choker.Acceleration ?? 0f) == 0f && (gun.Acceleration ?? 0f) == 0f, $"the choker and the gun author no motor at all, so they fly at VELOCITY throughout"); @@ -363,8 +402,8 @@ internal static void MotorAcceleration(TestContext ctx) var atOne = Fly(incendiary, 0f, 1f); var atTwo = Fly(incendiary, 0f, 2f); var atThree = Fly(incendiary, 0f, 3f); - ctx.Check(Mathf.Abs(atOne.Speed - 150f) < 0.5f && Mathf.Abs(atTwo.Speed - 300f) < 0.5f, - $"a motor round off a standing launcher climbs at its authored rate 1s={atOne.Speed:0.#} 2s={atTwo.Speed:0.#} expected=150/300"); + ctx.Check(Mathf.Abs(atOne.Speed - motor) < 0.5f && Mathf.Abs(atTwo.Speed - (2f * motor)) < 0.5f, + $"a motor round off a standing launcher climbs at its authored rate 1s={atOne.Speed:0.#} 2s={atTwo.Speed:0.#} expected={motor:0.#}/{2f * motor:0.#}"); ctx.Check(Mathf.Abs(atThree.Speed - cruise) < 0.5f, $"and reaches VELOCITY exactly as the motor's own arithmetic predicts speed={atThree.Speed:0.#} expected={cruise:0.#}"); // Its RANGE expires at 900 m, which it passes at ~3.46 s, so this is the last sample @@ -378,8 +417,16 @@ internal static void MotorAcceleration(TestContext ctx) // offset climb; Ballistics.LaunchSpeed carries the cap arithmetic under unit test. var launched = Fly(fake, 100f, 1f); var launchedLater = Fly(fake, 100f, 2f); - ctx.Check(Mathf.Abs(launched.Speed - 250f) < 0.5f && Mathf.Abs(launchedLater.Speed - 400f) < 0.5f, - $"a motor round off a 100 m/s launcher leaves at it and climbs from there 1s={launched.Speed:0.#} 2s={launchedLater.Speed:0.#} expected=250/400"); + float fakeMotor = fake.Acceleration ?? 0f; + float fakeCapped = (fake.Velocity ?? 0f) + 100f; + float fakeOne = Mathf.Min(100f + fakeMotor, fakeCapped), fakeTwo = Mathf.Min(100f + (2f * fakeMotor), fakeCapped); + ctx.Check(Mathf.Abs(launched.Speed - fakeOne) < 0.5f && Mathf.Abs(launchedLater.Speed - fakeTwo) < 0.5f, + $"a motor round off a 100 m/s launcher leaves at it and climbs from there 1s={launched.Speed:0.#} 2s={launchedLater.Speed:0.#} expected={fakeOne:0.#}/{fakeTwo:0.#}"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.Abs(launched.Speed - 250f) < 0.5f && Mathf.Abs(launchedLater.Speed - 400f) < 0.5f, + $"a motor round off a 100 m/s launcher leaves at it and climbs from there 1s={launched.Speed:0.#} 2s={launchedLater.Speed:0.#} expected=250/400"); + } var (fakeSpeed, fakeCap) = Ballistics.LaunchSpeed(fake.Velocity ?? 0f, fake.Acceleration ?? 0f, 100f); ctx.Check(Mathf.Abs(fakeCap - ((fake.Velocity ?? 0f) + 100f)) < 0.01f && Mathf.Abs(fakeSpeed - 100f) < 0.01f, $"and its cap is VELOCITY above the launcher's speed cap={fakeCap:0.#} launch={fakeSpeed:0.#}"); @@ -416,25 +463,42 @@ internal static void OrdnanceEndConditions(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_24", out var he) || !weapons.TryGet("wep_12", out var choker) - || !weapons.TryGet("wep_15", out var flare) || !weapons.TryGet("wep_08", out var sonic) - || !weapons.TryGet("wep_14", out var torpedo)) + if (PickWeapon(ctx, weapons, "wep_24", w => w.HighExplosive && ProjectilePool.CarriesLockOn(w) + && w.Range != null && (w.Acceleration ?? 0f) == 0f) is not { } he + || PickWeapon(ctx, weapons, "wep_12", w => w.Tangler != null) is not { } choker + || PickWeapon(ctx, weapons, "wep_15", w => w.DetonationTime is > 0f) is not { } flare + || PickWeapon(ctx, weapons, "wep_08", w => w.Sonic) is not { } sonic + || PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo) { ctx.Check(false, $"wep_24, wep_12, wep_15, wep_08 and wep_14 all resolve"); return; } - ctx.Check(he.Range is 1000f && choker.Range is 1000f && flare.Range is null, - $"the two 1000 m rounds author RANGE and the flare authors none, taking the engine's own {ProjectilePool.DefaultRange:0} m default"); + float fuseRange = sonic.DetonationDistance ?? 0f; + ctx.Check(he.Range is > 0f && choker.Range is > 0f && flare.Range is null, + $"{he.Id} and {choker.Id} author RANGE and the flare authors none, taking the engine's own {ProjectilePool.DefaultRange:0} m default"); ctx.Check(ProjectilePool.DetonatesAtRange(he) && !ProjectilePool.DetonatesAtRange(choker), $"a round detonates at RANGE only if its weapon carries LOCK_ON: the HE rocket does, the choker does not"); - ctx.Check(flare.DetonationTime is 2.0f && he.DetonationTime is null, - $"the rear-arc flare is the one weapon authoring a timed fuse, at 2.0 s"); - ctx.Check(sonic.DetonationDistance is 35f && sonic.DetonationDistanceSqM is 1225f, - $"the sonic's DETONATION_DISTANCE is 35 m, stored squared as 1225 the way the engine keeps it"); - ctx.Check(torpedo.RangeMinimum is 300f && torpedo.FlyoutHealth is 10 + ctx.Check(flare.DetonationTime is > 0f && he.DetonationTime is null, + $"{flare.Id} authors a timed fuse of {flare.DetonationTime:0.##} s and the HE rocket none"); + // The own target stands 30 m off the flight line, so only a fuse wider than that reaches it. + ctx.Check(fuseRange > 30f && sonic.DetonationDistanceSqM is { } fuseSq && Mathf.IsEqualApprox(fuseSq, fuseRange * fuseRange), + $"the sonic's DETONATION_DISTANCE is {fuseRange:0.#} m, stored squared as {sonic.DetonationDistanceSqM:0.#} the way the engine keeps it"); + ctx.Check(torpedo.RangeMinimum is > 0f && torpedo.FlyoutHealth is > 0 && ProjectilePool.FlyoutUnhittableAtLaunch(torpedo) && !ProjectilePool.FlyoutUnhittableAtLaunch(he), - $"the torpedo alone carries both halves of the intersect gate (RANGE_MINIMUM 300 m and FLYOUT_HEALTH)"); + $"the torpedo alone carries both halves of the intersect gate (RANGE_MINIMUM {torpedo.RangeMinimum:0} m and FLYOUT_HEALTH)"); + if (!ctx.SyntheticData) + { + ctx.Check(he.Range is 1000f && choker.Range is 1000f && flare.Range is null, + $"the two 1000 m rounds author RANGE and the flare authors none, taking the engine's own {ProjectilePool.DefaultRange:0} m default"); + ctx.Check(flare.DetonationTime is 2.0f && he.DetonationTime is null, + $"the rear-arc flare is the one weapon authoring a timed fuse, at 2.0 s"); + ctx.Check(sonic.DetonationDistance is 35f && sonic.DetonationDistanceSqM is 1225f, + $"the sonic's DETONATION_DISTANCE is 35 m, stored squared as 1225 the way the engine keeps it"); + ctx.Check(torpedo.RangeMinimum is 300f && torpedo.FlyoutHealth is 10 + && ProjectilePool.FlyoutUnhittableAtLaunch(torpedo) && !ProjectilePool.FlyoutUnhittableAtLaunch(he), + $"the torpedo alone carries both halves of the intersect gate (RANGE_MINIMUM 300 m and FLYOUT_HEALTH)"); + } var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); @@ -499,21 +563,38 @@ internal static void OrdnanceEndConditions(TestContext ctx) $"the choker flies the same full RANGE and expires with no detonation at all travelled={quiet.Travelled:0.#} m"); // 2. The timed fuse, with nothing near and 498 m of range left unspent. + float fuse = flare.DetonationTime ?? 0f; var fused = Fly(flare, null, 4f); float fuseSeconds = fused.Steps / 60f; - ctx.Check(fused.At != null && Mathf.Abs(fuseSeconds - 2f) < 0.05f, - $"the flare detonates on its 2.0 s fuse t={fuseSeconds:0.###} s"); - ctx.Check(fused.Travelled < 5f, - $"and does so two metres from the launcher, nowhere near a range expiry travelled={fused.Travelled:0.##} m"); + ctx.Check(fused.At != null && Mathf.Abs(fuseSeconds - fuse) < 0.05f, + $"the flare detonates on its {fuse:0.0} s fuse t={fuseSeconds:0.###} s"); + ctx.Check(fused.Travelled <= ((flare.Velocity ?? ProjectilePool.DefaultVelocity) * fuse) + 0.5f && fused.Travelled < ProjectilePool.DefaultRange / 2f, + $"and does so at its own speed's reach from the launcher, nowhere near a range expiry travelled={fused.Travelled:0.##} m"); + if (!ctx.SyntheticData) + { + ctx.Check(fused.Travelled < 5f, + $"and does so two metres from the launcher, nowhere near a range expiry travelled={fused.Travelled:0.##} m"); + } - // 3. The round's own target, and the LOCK_ON half of its gate. + // 3. The round's own target, and the LOCK_ON half of its gate. The fuse is read at the + // start of each step, so it fires on the first step that starts inside DETONATION_DISTANCE. + float sonicStep = (sonic.Velocity ?? ProjectilePool.DefaultVelocity) / 60f; + float fuseEdge = 300f - Mathf.Sqrt((fuseRange * fuseRange) - 900f); var onTarget = Fly(sonic, mark, 3f); float onTargetRange = onTarget.At?.DistanceTo(origin) ?? 0f; - ctx.Check(onTarget.At != null && onTargetRange > 270f && onTargetRange < 295f, - $"a sonic holding a target detonates as it comes within DETONATION_DISTANCE of it at={onTargetRange:0.#} m"); + ctx.Check(onTarget.At != null && onTargetRange > fuseEdge - 0.5f && onTargetRange < fuseEdge + sonicStep + 0.5f, + $"a sonic holding a target detonates as it comes within DETONATION_DISTANCE of it at={onTargetRange:0.#} m edge={fuseEdge:0.#} m step={sonicStep:0.#} m"); var noTarget = Fly(sonic, null, 3f); - ctx.Check(noTarget.At is { } freeAt && freeAt.DistanceTo(origin) > 900f, + float sonicRange = sonic.Range ?? ProjectilePool.DefaultRange; + ctx.Check(noTarget.At is { } freeAt && freeAt.DistanceTo(origin) > sonicRange - sonicStep - 0.5f, $"the same round holding NO target flies past that point to its RANGE at={noTarget.At?.DistanceTo(origin):0.#} m"); + if (!ctx.SyntheticData) + { + ctx.Check(onTarget.At != null && onTargetRange > 270f && onTargetRange < 295f, + $"a sonic holding a target detonates as it comes within DETONATION_DISTANCE of it at={onTargetRange:0.#} m"); + ctx.Check(noTarget.At is { } shippedAt && shippedAt.DistanceTo(origin) > 900f, + $"the same round holding NO target flies past that point to its RANGE at={noTarget.At?.DistanceTo(origin):0.#} m"); + } var gated = Fly(choker, mark, 3f); ctx.Check(gated.At == null && gated.Travelled > 500f, $"and a weapon without LOCK_ON holding the same target flies past it untouched travelled={gated.Travelled:0.#} m"); @@ -610,7 +691,9 @@ internal static void ShootableFlyout(TestContext ctx) ctx.RequireData(texturesPath, $"C1 textures"); ctx.RequireData(ctx.MessagesPath, $"messages.json"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, Messages.Load(ctx.MessagesPath)); - if (!weapons.TryGet("wep_14", out var torpedo) || !weapons.TryGet("wep_06", out var rocket)) + if (PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo + || PickWeapon(ctx, weapons, "wep_06", w => w.HighExplosive && !w.Targetable + && w.DetonationDistance is > AimAssist.MinFuseDistance) is not { } rocket) { ctx.Check(false, $"wep_14 and wep_06 both resolve"); return; @@ -624,9 +707,17 @@ internal static void ShootableFlyout(TestContext ctx) } // --- the authored halves --------------------------------------------------------------- - ctx.Check(torpedo.Targetable && torpedo.FlyoutHealth is 10 && torpedo.ProjectileBbox is 0 - && torpedo.DestroyAnimation == "torpedo_destroy_effect", - $"wep_14 is the one entry carrying TARGETABLE, FLYOUT_HEALTH 10, PROJECTILE_BBOX 0 and a DESTROY_ANIMATION"); + // Every count below is read off the torpedo's own pool; the first hit spends 4 of it. + float pool0 = torpedo.FlyoutHealth ?? 0; + ctx.Check(torpedo.Targetable && pool0 > 4f && torpedo.ProjectileBbox is 0 + && torpedo.DestroyAnimation != null, + $"{torpedo.Id} is the one entry carrying TARGETABLE, FLYOUT_HEALTH {pool0:0}, PROJECTILE_BBOX 0 and a DESTROY_ANIMATION"); + if (!ctx.SyntheticData) + { + ctx.Check(torpedo.Targetable && torpedo.FlyoutHealth is 10 && torpedo.ProjectileBbox is 0 + && torpedo.DestroyAnimation == "torpedo_destroy_effect", + $"wep_14 is the one entry carrying TARGETABLE, FLYOUT_HEALTH 10, PROJECTILE_BBOX 0 and a DESTROY_ANIMATION"); + } ctx.Check(!rocket.Targetable && rocket.FlyoutHealth == null && rocket.DestroyAnimation == null && rocket.DetonationDistance is > AimAssist.MinFuseDistance, $"wep_06 carries neither key though it IS fused, so it reaches the same fourth pool with the admission byte clear"); @@ -635,13 +726,13 @@ internal static void ShootableFlyout(TestContext ctx) // --- the pair's arithmetic, off the round's own state ------------------------------------ var seeded = ProjectilePool.SeedFlyout(torpedo, 3); - ctx.Check(seeded is { Targetable: true, Armour: 0f, Health: 10f, HealthMax: 10f } - && seeded.Name == "wep_14#3", + ctx.Check(seeded is { Targetable: true, Armour: 0f } && seeded.Health == pool0 && seeded.HealthMax == pool0 + && seeded.Name == $"{torpedo.Id}#3", $"a torpedo's pair seeds from weapon +0x8c/+0x90: armour {seeded?.Armour} (the parser's literal 0) and health {seeded?.Health}"); ctx.Check(ProjectilePool.SeedFlyout(rocket, 0) == null, $"an ordinary rocket gets no flyout state at all, which is the −1.0 sentinel's outcome without the per-round allocation"); seeded!.Spend(999f, 4f); - ctx.Check(seeded is { Armour: 0f, Health: 6f }, + ctx.Check(seeded.Armour == 0f && seeded.Health == pool0 - 4f, $"the first hit spends HEALTH directly because the armour pool is already empty health={seeded.Health}"); seeded.Spend(0f, 100f); ctx.Check(seeded is { Health: 0f, Destroyed: true }, @@ -676,7 +767,7 @@ internal static void ShootableFlyout(TestContext ctx) var flyouts = new List(); live.CollectFlyouts(flyouts); - ctx.Check(flyouts.Count == 1 && flyouts[0].Targetable && flyouts[0].Health == 10f, + ctx.Check(flyouts.Count == 1 && flyouts[0].Targetable && flyouts[0].Health == pool0, $"exactly one of the two live rounds carries flyout state count={flyouts.Count}"); var scan = new AimCandidateSet(); @@ -695,9 +786,15 @@ internal static void ShootableFlyout(TestContext ctx) && sel.Pool.NonAircraft.Count == 0, $"the torpedo is the pool's one entry and lands on the ENEMY cycle, the ordinary rocket contributing nothing"); var round = sel.Pool.Enemy[0]; - ctx.Check(round.Kind == AimTargetKind.Ordnance && round.Name == "wep_14#0" - && round.DisplayName == "Aerial torpedo" && round.TypeLabel == null, + ctx.Check(round.Kind == AimTargetKind.Ordnance && round.Name == $"{torpedo.Id}#0" + && round.DisplayName == torpedo.DisplayName && round.TypeLabel == null, $"…named for --target= and labelled with the weapon's own DESC name='{round.Name}' display='{round.DisplayName}'"); + if (!ctx.SyntheticData) + { + ctx.Check(round.Kind == AimTargetKind.Ordnance && round.Name == "wep_14#0" + && round.DisplayName == "Aerial torpedo" && round.TypeLabel == null, + $"…named for --target= and labelled with the weapon's own DESC name='{round.Name}' display='{round.DisplayName}'"); + } ctx.Check(round.Health is { } h && Mathf.IsEqualApprox(h, 1f) && round.Armor == null, $"…carrying a full health bar and no armour figure at all health={round.Health}"); ctx.Check(round.SortsFirst && sel.Current is { } cur && cur.IsSameTarget(round), @@ -731,15 +828,15 @@ internal static void ShootableFlyout(TestContext ctx) flyouts[0].Spend(gun.ArmorDamage ?? 0f, gun.HealthDamage ?? 0f); shots++; } - int expected = Mathf.CeilToInt(10f / (gun.HealthDamage ?? 1f)); + int expected = Mathf.CeilToInt(pool0 / (gun.HealthDamage ?? 1f)); ctx.Check(shots == expected, - $"{gun.Id}'s HEALTH_DAMAGE {gun.HealthDamage} empties the 10-point pool in {shots} hits (expected {expected})"); + $"{gun.Id}'s HEALTH_DAMAGE {gun.HealthDamage} empties the {pool0:0}-point pool in {shots} hits (expected {expected})"); live.SimStep(1f / 60f); flyouts.Clear(); live.CollectFlyouts(flyouts); ctx.Check(flyouts.Count == 0, $"the frame check destroys the round the step after its health reaches zero"); - ctx.Check(effects.Count == 1 && effects[0].Name == "torpedo_destroy_effect", + ctx.Check(effects.Count == 1 && effects[0].Name == torpedo.DestroyAnimation, $"…playing DESTROY_ANIMATION and NOTHING else: no impact effect, no detonation ({string.Join(",", effects.Select(e => e.Name))})"); } finally @@ -748,18 +845,18 @@ internal static void ShootableFlyout(TestContext ctx) textures.Dispose(); } - // --- the same thing through a REAL hit ray, which needs a real flyout body --------------- - // The box is built over the round's instanced FLYOUT MODEL, so this half wants a chapter - // gamez; C1 carries a_torpedo. Built without collision, so nothing but the round is solid. - ctx.WithWorld("C1", collision: false, world => + // --- the same thing through a REAL hit ray, over a real flyout body --------------------- + // The box is built over the round's instanced FLYOUT MODEL. C1's a_torpedo carries one, and so + // does the synthetic templates' body. Nothing but the round is solid. + EffectStageSuiteHelper.WithAnimSource(ctx, "C1", source => { var worldTextures = new TextureArchive(texturesPath); ProjectilePool? live = null; try { live = new ProjectilePool(worldTextures, null, null, - flyoutGamez: world.Gamez, flyoutScene: world.Session.Builder.Scene, - flyoutAnims: world.Session.Program); + flyoutGamez: source.Gamez, flyoutScene: source.Scene, + flyoutAnims: source.Program); ctx.Host.AddChild(live); var origin = new Vector3(6000f, 3000f, 0f); @@ -815,14 +912,14 @@ float ShootIt() // Inside RANGE_MINIMUM the intersect bit is clear, so gunfire passes through the // drawn body. float early = ShootIt(); - ctx.Check(Mathf.IsEqualApprox(early, 10f), + ctx.Check(Mathf.IsEqualApprox(early, pool0), $"a torpedo still inside RANGE_MINIMUM takes nothing from a round straight through it health={early}"); for (int i = 0; i < 60 * 6; i++) live.SimStep(1f / 60f); float after = ShootIt(); - ctx.Check(after >= 0f && after < 10f, - $"once revealed, the same shot spends {10f - after} of its 10 points through the real hit ray health={after}"); + ctx.Check(after >= 0f && after < pool0, + $"once revealed, the same shot spends {pool0 - after} of its {pool0:0} points through the real hit ray health={after}"); int hits = 1; while (flyouts.Count > 0 && !flyouts[0].Destroyed && hits < 40) @@ -831,7 +928,7 @@ float ShootIt() hits++; } ctx.Check(flyouts.Count > 0 && flyouts[0].Destroyed - && hits == Mathf.CeilToInt(10f / (gun.HealthDamage ?? 1f)), + && hits == Mathf.CeilToInt(pool0 / (gun.HealthDamage ?? 1f)), $"and {hits} hits of {gun.Id} empty the pool, the count its HEALTH_DAMAGE {gun.HealthDamage} predicts"); live.SimStep(1f / 60f); flyouts.Clear(); @@ -874,6 +971,8 @@ internal static void FlyoutRackPose(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); ctx.RequireData(ctx.MessagesPath, $"messages.json"); + // Every check reads a body built off C1's FLYOUT models, so the world gates the whole suite. + ctx.RequireData(SessionPaths.ChapterGamez(ctx.DataRoot, "C1"), $"chapter C1 gamez"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, Messages.Load(ctx.MessagesPath)); if (!weapons.TryGet("wep_15", out var flare) || !weapons.TryGet("wep_14", out var torpedo)) { @@ -1089,19 +1188,30 @@ internal static void OrdnanceGuidance(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_11", out var seeker) || !weapons.TryGet("wep_14", out var torpedo) - || !weapons.TryGet("wep_10", out var beeper)) + if (PickWeapon(ctx, weapons, "wep_11", w => w.BeeperSeeker) is not { } seeker + || PickWeapon(ctx, weapons, "wep_14", w => w.Torpedo) is not { } torpedo + || PickWeapon(ctx, weapons, "wep_10", w => w.BeeperTime != null) is not { } beeper) { ctx.Check(false, $"wep_11, wep_14 and wep_10 all resolve"); return; } const float dt = 1f / 60f; - ctx.Check(seeker.TurnRate is 1.25f && seeker.BeeperSeeker && seeker.LockOnLead == null - && torpedo.TurnRate is > 0.0009f and < 0.0011f && beeper.BeeperTime is 20f, - $"wep_11 authors TURN_RATE 1.25 and BEEPER_SEEKER with no LOCK_ON_LEAD, wep_14 the 0.001 sentinel, wep_10 TIME 20"); + // A homing rate, a sentinel-scale one, and a paint long enough to read on and then off. + ctx.Check(seeker.TurnRate is > 0.01f && seeker.BeeperSeeker && seeker.LockOnLead == null + && ProjectilePool.CarriesLockOn(seeker) && torpedo.TurnRate is > 0f and < 0.01f + && ProjectilePool.CarriesLockOn(torpedo) && beeper.BeeperTime is > 1f, + $"{seeker.Id} authors TURN_RATE {seeker.TurnRate:0.##} and BEEPER_SEEKER with no LOCK_ON_LEAD, {torpedo.Id} the {torpedo.TurnRate:0.###} sentinel, {beeper.Id} TIME {beeper.BeeperTime:0.#}"); var carriers = weapons.All.Where(w => w.LockOnLead != null).ToList(); - ctx.Check(carriers.Count == 3 && carriers.All(w => w.TurnRate is < 0.01f), + ctx.Check(carriers.Count > 0 && carriers.All(w => w.TurnRate is < 0.01f), $"LOCK_ON_LEAD's carriers ({string.Join(",", carriers.Select(w => w.Id))}) all pair it with the sentinel rate"); + if (!ctx.SyntheticData) + { + ctx.Check(seeker.TurnRate is 1.25f && seeker.BeeperSeeker && seeker.LockOnLead == null + && torpedo.TurnRate is > 0.0009f and < 0.0011f && beeper.BeeperTime is 20f, + $"wep_11 authors TURN_RATE 1.25 and BEEPER_SEEKER with no LOCK_ON_LEAD, wep_14 the 0.001 sentinel, wep_10 TIME 20"); + ctx.Check(carriers.Count == 3 && carriers.All(w => w.TurnRate is < 0.01f), + $"LOCK_ON_LEAD's carriers ({string.Join(",", carriers.Select(w => w.Id))}) all pair it with the sentinel rate"); + } var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); @@ -1192,24 +1302,31 @@ static float AngleFrom(Vector3 dir, Vector3 reference) => live.SimStep(dt); var dumb = Round(); float dumbTurn = dumb is { } db ? AngleFrom(db.Vel, Vector3.Forward) : -1f; - ctx.Check(dumb != null && dumbTurn > 0.003f && dumbTurn < 0.005f, - $"a sentinel-rate round with a target flies effectively straight, turning only its 0.001 rad/s turned={Mathf.RadToDeg(dumbTurn):0.###}° in 4 s"); + float dumbExpected = ProjectilePool.MaxTurnRad(torpedo, dt) * Mathf.RoundToInt(4f / dt); + ctx.Check(dumb != null && dumbTurn > 0.75f * dumbExpected && dumbTurn < 1.25f * dumbExpected, + $"a sentinel-rate round with a target flies effectively straight, turning only its {torpedo.TurnRate:0.###} rad/s turned={Mathf.RadToDeg(dumbTurn):0.###}° in 4 s"); // 3. No target: the launcher's velocity still decays (the recorded divergence) and the // heading never moves. live.Clear(); live.Spawn(torpedo, muzzle, Vector3.Forward * 120f); - for (int i = 0; i < Mathf.RoundToInt(2.5f / dt); i++) + for (int i = 0; i < Mathf.RoundToInt((torpedo.LockOn ?? 0f) / dt); i++) live.SimStep(dt); var free = Round(); float freeTurn = free is { } fr ? AngleFrom(fr.Vel, Vector3.Forward) : -1f; - ctx.Check(free != null && Mathf.Abs(free.Value.Vel.Length() - 60f) < 0.5f && freeTurn < 1e-4f, - $"a LOCK_ON round holding no target decays to its own 60 m/s and does not turn speed={free?.Vel.Length():0.##} turned={Mathf.RadToDeg(freeTurn):0.####}°"); + float ownSpeed = torpedo.Velocity ?? 0f; + ctx.Check(free != null && Mathf.Abs(free.Value.Vel.Length() - ownSpeed) < 0.5f && freeTurn < 1e-4f, + $"a LOCK_ON round holding no target decays to its own {ownSpeed:0} m/s and does not turn speed={free?.Vel.Length():0.##} turned={Mathf.RadToDeg(freeTurn):0.####}°"); + if (!ctx.SyntheticData) + { + ctx.Check(free != null && Mathf.Abs(free.Value.Vel.Length() - 60f) < 0.5f && freeTurn < 1e-4f, + $"a LOCK_ON round holding no target decays to its own 60 m/s and does not turn speed={free?.Vel.Length():0.##} turned={Mathf.RadToDeg(freeTurn):0.####}°"); + } // 4. LOCK_ON_LEAD. No shipped carrier reaches its element 0 before RANGE (wep_04 off a // standing launcher, its slowest case, is gone by 3.5 s of its 4 s onset), so the blend // is exercised on a lab def below, snapping to the desired direction every frame. - if (weapons.TryGet("wep_04", out var incendiary)) + if (PickWeapon(ctx, weapons, "wep_04", w => w.LockOnLead != null && w.Acceleration is > 0f) is { } incendiary) { live.Clear(); live.Spawn(incendiary, muzzle, Vector3.Zero, target: mark); @@ -1221,7 +1338,7 @@ static float AngleFrom(Vector3 dir, Vector3 reference) => } float ended = alive * dt; ctx.Check(ended < (incendiary.LockOnLead?.Item1 ?? 0f), - $"wep_04 off a standing launcher ends at {ended:0.##} s, before its LOCK_ON_LEAD onset at {incendiary.LockOnLead?.Item1:0} s"); + $"{incendiary.Id} off a standing launcher ends at {ended:0.##} s, before its LOCK_ON_LEAD onset at {incendiary.LockOnLead?.Item1:0} s"); } // The heading after each step is that frame's desired direction, compared with the // bearing and the AimAssist.TryIntercept solve computed off the same pre-step state of @@ -1368,13 +1485,14 @@ int Paint() $"a wep_10 into a hostile rig paints it for TIME steps={hitSteps} remaining={remaining:0.###} of {beeper.BeeperTime:0}"); ctx.Check(Pristine(victim) && victim.InPlay, $"and the damage ledger is untouched: no zone lost a point"); - for (int i = 0; i < Mathf.RoundToInt(19.5f / dt) - hitSteps; i++) + float paintSeconds = beeper.BeeperTime ?? 0f; + for (int i = 0; i < Mathf.RoundToInt((paintSeconds - 0.5f) / dt) - hitSteps; i++) tags.SimStep(dt); bool paintedLate = tags.IsPainted(victim); for (int i = 0; i < Mathf.RoundToInt(0.6f / dt); i++) tags.SimStep(dt); ctx.Check(paintedLate && !tags.IsPainted(victim), - $"the paint lasts TIME: still on at 19.5 s, off past 20 s"); + $"the paint lasts TIME: still on at {paintSeconds - 0.5f:0.#} s, off past {paintSeconds:0.#} s"); int again = Paint(); bool repainted = tags.IsPainted(victim); victim.DebugForceCrash(); @@ -1412,16 +1530,25 @@ internal static void SmokeScreenSuite(TestContext ctx) ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_13", out var smoker) || smoker.SmokeScreenTime is not { } screenTime) + if (PickWeapon(ctx, weapons, "wep_13", w => w.SmokeScreenTime != null) is not { } smoker + || smoker.SmokeScreenTime is not { } screenTime) { ctx.Check(false, $"wep_13 resolves and carries a SMOKE_SCREEN TIME"); return; } - ctx.Check(Mathf.IsEqualApprox(screenTime, 8f), $"wep_13 authors SMOKE_SCREEN TIME {screenTime:0.#}, the decode's 8 s"); var tunables = SmokeScreenTunables.Load(ctx.ZrdrPath); - ctx.Check(Mathf.IsEqualApprox(tunables.RangeM, 600f) && Mathf.IsEqualApprox(tunables.StunIntervalS, 5f) - && Mathf.Abs(tunables.HalfAngleCos - Mathf.Cos(Mathf.DegToRad(85f))) < 1e-5f, - $"player.json reads 600 m, 5 s and 170° stored as cos 85° ({tunables.RangeM:0}/{tunables.StunIntervalS:0}/{tunables.HalfAngleCos:0.####})"); + // The layout below puts an AI 300 m astern inside the cone and one 400 m out at 97° outside it. + float coneDeg = Mathf.RadToDeg(Mathf.Acos(tunables.HalfAngleCos)); + ctx.Check(screenTime > 3f && tunables.RangeM > 400f && coneDeg < 97f && coneDeg > 1f + && tunables.RangeM != SmokeScreenTunables.Image.RangeM, + $"{smoker.Id} authors SMOKE_SCREEN TIME {screenTime:0.#} and player.json reads {tunables.RangeM:0} m, {tunables.StunIntervalS:0.#} s and a {coneDeg:0.#}° half-angle, its own keys and not the loader's defaults"); + if (!ctx.SyntheticData) + { + ctx.Check(Mathf.IsEqualApprox(screenTime, 8f), $"wep_13 authors SMOKE_SCREEN TIME {screenTime:0.#}, the decode's 8 s"); + ctx.Check(Mathf.IsEqualApprox(tunables.RangeM, 600f) && Mathf.IsEqualApprox(tunables.StunIntervalS, 5f) + && Mathf.Abs(tunables.HalfAngleCos - Mathf.Cos(Mathf.DegToRad(85f))) < 1e-5f, + $"player.json reads 600 m, 5 s and 170° stored as cos 85° ({tunables.RangeM:0}/{tunables.StunIntervalS:0}/{tunables.HalfAngleCos:0.####})"); + } var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); @@ -1497,8 +1624,12 @@ FlightController BuildRig(int playerIndex, bool human, Vector3 pos) var (chapterAnim, _) = AnimProgram.ArchivePaths(ctx.DataRoot, "C1", "IA1"); var authored = new SmokeScreenEmitters( AnimArchive.Load(chapterAnim, "cam_anim")?.Defs, textures, ctx.Host); - ctx.Check(authored.StateCount == 2, - $"generate_smokescreen authors {authored.StateCount} DISTANCE_INTERVAL puffer state(s), the decode's two"); + // The synthetic tree carries no chapter anim archive, so the authored cloud is the install's alone. + if (!ctx.SyntheticData) + { + ctx.Check(authored.StateCount == 2, + $"generate_smokescreen authors {authored.StateCount} DISTANCE_INTERVAL puffer state(s), the decode's two"); + } if (authored.StateCount == 2) SmokeScreenCloud(ctx, authored.States[0]); @@ -1542,8 +1673,9 @@ void Step() $"the per-step refresh holds the AI's remaining stun at the interval for the whole screen lowest={lowestStun:0.00}"); ctx.Check(!aiSide.Pilot.IsStunned, $"the AI beyond 85° stays untouched for the whole screen"); int rewashes = washes.Count(w => w.Player == 1) - 1; - ctx.Check(rewashes >= 4 && washes.Where(w => w.Player == 1).Skip(1).All(w => Mathf.IsEqualApprox(w.Weight, 0.9f)), - $"the human behind is re-washed at 0.9 while it stays inside rewashes={rewashes} (1.5 s apart over 8 s)"); + int rewashesOwed = (int)((screenTime - 1.5f) / 1.5f); + ctx.Check(rewashes >= rewashesOwed && washes.Where(w => w.Player == 1).Skip(1).All(w => Mathf.IsEqualApprox(w.Weight, 0.9f)), + $"the human behind is re-washed at 0.9 while it stays inside rewashes={rewashes} (1.5 s apart over {screenTime:0.#} s, at least {rewashesOwed})"); var gaps = washes.Where(w => w.Player == 1).Select(w => w.Time).ToList(); bool spaced = true; for (int i = 1; i < gaps.Count; i++) @@ -1556,7 +1688,7 @@ void Step() $"the human behind's pane carries the grey-green wash ({pane2})"); ctx.Check(flash.CurrentFor(0).IsEqualApprox(new Color(0f, 0f, 0f, 0f)), $"the layer's own pane stays clear ({flash.CurrentFor(0)})"); - ctx.Check(!laid[0].Stopped && laid[0].Steps > 400 + ctx.Check(!laid[0].Stopped && laid[0].Steps > screenTime * 50f && laid[0].LastPos.IsEqualApprox(layer.WorldPosition) && laid[0].LastBasis.Z.IsEqualApprox(-layer.NoseDirection), $"the emitter runs at the layer's live pose for the whole screen steps={laid[0].Steps}"); @@ -1623,17 +1755,28 @@ internal static void DisablingHits(TestContext ctx) ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_08", out var sonic) || !weapons.TryGet("wep_09", out var flashRocket) - || !weapons.TryGet("wep_12", out var choker)) + if (PickWeapon(ctx, weapons, "wep_08", w => w.Sonic) is not { } sonic + || PickWeapon(ctx, weapons, "wep_09", w => w.Flash && w.IsRocket && w.DetonationTime == null) is not { } flashRocket + || PickWeapon(ctx, weapons, "wep_12", w => w.Tangler != null) is not { } choker) { ctx.Check(false, $"wep_08, wep_09 and wep_12 all resolve"); return; } - ctx.Check(sonic.Sonic && !sonic.Flash && sonic.ImpactProximity is 35f && flashRocket.Flash - && flashRocket.ImpactProximity is 450f && choker.Tangler is { Radius: 35f, Time: 2f }, - $"wep_08 is SONIC at 35 m, wep_09 FLASH at 450 m, wep_12 a TANGLER of RADIUS 35 and TIME 2"); + // The layout reads the sonic's fade band 29 to 33 m off a hull. Its radius must reach past + // 33 m with the plateau short of 29 m. The choker's cloud must take in a pilot 20 m out. + ctx.Check(sonic.Sonic && !sonic.Flash && sonic.ImpactProximity is > 33f and < 37f && flashRocket.Flash + && flashRocket.ImpactProximity is > 0f && choker.Tangler is { Radius: > 25f, Time: > 1f }, + $"{sonic.Id} is SONIC at {sonic.ImpactProximity:0.#} m, {flashRocket.Id} FLASH at {flashRocket.ImpactProximity:0.#} m, {choker.Id} a TANGLER of RADIUS {choker.Tangler?.Radius:0.#} and TIME {choker.Tangler?.Time:0.#}"); var bounds = TanglerChoke.EngineDeadBounds(weapons); - ctx.Check(bounds == (5f, 13f), $"the catalogue's ENGINE_DEAD pair is [5, 13] ({bounds.Min}, {bounds.Max})"); + ctx.Check(choker.Tangler?.EngineDead is { } dead && bounds == dead && bounds.Min > 0f && bounds.Max > bounds.Min, + $"the catalogue's ENGINE_DEAD pair is the choker's own ({bounds.Min}, {bounds.Max})"); + if (!ctx.SyntheticData) + { + ctx.Check(sonic.Sonic && !sonic.Flash && sonic.ImpactProximity is 35f && flashRocket.Flash + && flashRocket.ImpactProximity is 450f && choker.Tangler is { Radius: 35f, Time: 2f }, + $"wep_08 is SONIC at 35 m, wep_09 FLASH at 450 m, wep_12 a TANGLER of RADIUS 35 and TIME 2"); + ctx.Check(bounds == (5f, 13f), $"the catalogue's ENGINE_DEAD pair is [5, 13] ({bounds.Min}, {bounds.Max})"); + } var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); @@ -1835,25 +1978,32 @@ void Advance(float seconds) // choked for the formula's seconds at its ORIGIN distance, the human 20 m out for the // floor, both refreshed every step the cloud lives, and the timer runs once it is gone. var clouds = new List<(Vector3 Centre, float Remaining)>(); + float cloudTime = choker.Tangler!.Time ?? 0f; steps = Fire(choker, aiNear.WorldPosition + new Vector3(0f, 30f, 0f), Vector3.Down); live.CollectTanglerClouds(clouds); - ctx.Check(steps < 120 && clouds.Count == 1 && Mathf.Abs(clouds[0].Remaining - (2f - steps * dt)) < 0.02f, - $"a wep_12 into the AI leaves one cloud running its 2 s TIME (clouds={clouds.Count} remaining={(clouds.Count > 0 ? clouds[0].Remaining : 0f):0.00} after {steps} steps)"); + ctx.Check(steps < 120 && clouds.Count == 1 && Mathf.Abs(clouds[0].Remaining - (cloudTime - steps * dt)) < 0.02f, + $"a wep_12 into the AI leaves one cloud running its {cloudTime:0.#} s TIME (clouds={clouds.Count} remaining={(clouds.Count > 0 ? clouds[0].Remaining : 0f):0.00} after {steps} steps)"); float originSq = clouds.Count > 0 ? aiNear.WorldPosition.DistanceSquaredTo(clouds[0].Centre) : 0f; float expected = TanglerChoke.Duration(originSq, choker.Tangler!.Radius!.Value, bounds.Min, bounds.Max); - ctx.Check(expected > 10f && Mathf.Abs(aiNear.EngineDeadRemainingS - expected) < 0.05f, + // Above the floor, so the struck AI reads the formula and not the floor the far pilot reads. + ctx.Check(expected > bounds.Min + 1f && Mathf.Abs(aiNear.EngineDeadRemainingS - expected) < 0.05f, $"the struck AI's engine is dead for the formula's {expected:0.00} s at {Mathf.Sqrt(originSq):0.##} m from its origin (remaining={aiNear.EngineDeadRemainingS:0.00})"); + if (!ctx.SyntheticData) + { + ctx.Check(expected > 10f && Mathf.Abs(aiNear.EngineDeadRemainingS - expected) < 0.05f, + $"the struck AI's engine is dead for the formula's {expected:0.00} s at {Mathf.Sqrt(originSq):0.##} m from its origin (remaining={aiNear.EngineDeadRemainingS:0.00})"); + } ctx.Check(Mathf.Abs(human1.EngineDeadRemainingS - bounds.Min) < 0.05f, $"the human 20 m out, inside RADIUS, is choked for the {bounds.Min:0} s floor (remaining={human1.EngineDeadRemainingS:0.00})"); ctx.Check(human0.EngineDeadRemainingS == 0f && aiFar.EngineDeadRemainingS == 0f, $"nobody outside the cloud is touched"); ctx.Check(Pristine(aiNear) && Pristine(human1), $"the choker spent nothing on either ledger"); - Advance(1.5f); + Advance(cloudTime * 0.75f); clouds.Clear(); live.CollectTanglerClouds(clouds); ctx.Check(clouds.Count == 1 && Mathf.Abs(aiNear.EngineDeadRemainingS - expected) < 0.05f, $"while the cloud lives the choke is refreshed every step (remaining={aiNear.EngineDeadRemainingS:0.00} at cloud {(clouds.Count > 0 ? clouds[0].Remaining : 0f):0.00} s left)"); - Advance(0.6f); + Advance((cloudTime * 0.25f) + 0.1f); clouds.Clear(); live.CollectTanglerClouds(clouds); ctx.Check(clouds.Count == 0, $"the cloud is gone at its TIME (clouds={clouds.Count})"); @@ -1861,7 +2011,7 @@ void Advance(float seconds) aiNear.Held = false; float atRelease = aiNear.EngineDeadRemainingS; Advance(3f); - ctx.Check(atRelease > 10f && Mathf.Abs(aiNear.EngineDeadRemainingS - (atRelease - 3f)) < 0.1f, + ctx.Check(atRelease > Mathf.Max(bounds.Min + 1f, 3.5f) && Mathf.Abs(aiNear.EngineDeadRemainingS - (atRelease - 3f)) < 0.1f, $"once the cloud is gone the engine timer runs down ({atRelease:0.00} → {aiNear.EngineDeadRemainingS:0.00} over 3 s)"); } finally @@ -1894,16 +2044,24 @@ internal static void ImpactOrientation(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_06", out var he) || !weapons.TryGet("wep_12", out var choker)) + if (PickWeapon(ctx, weapons, "wep_06", w => w.ImpactFor(SurfaceRegistry.Default) is { Animation: null, SurfaceAnimation: not null }) is not { } he + || PickWeapon(ctx, weapons, "wep_12", w => w.Tangler != null && w.ImpactFor(SurfaceRegistry.Default) is { Animation: not null, SurfaceAnimation: null }) is not { } choker) { ctx.Check(false, $"wep_06 and wep_12 resolve"); return; } var heRow = he.ImpactFor(SurfaceRegistry.Default); var chokerRow = choker.ImpactFor(SurfaceRegistry.Default); - ctx.Check(heRow is { Animation: null, SurfaceAnimation: "he_ground_effect" } - && chokerRow is { Animation: "scatter_effect", SurfaceAnimation: null }, - $"wep_06's default row binds SURFACE_ANIMATION he_ground_effect and wep_12's a plain ANIMATION scatter_effect"); + string heFx = heRow?.SurfaceAnimation ?? "-", chokerFx = chokerRow?.Animation ?? "-"; + ctx.Check(heRow is { Animation: null, SurfaceAnimation: not null } + && chokerRow is { Animation: not null, SurfaceAnimation: null }, + $"{he.Id}'s default row binds SURFACE_ANIMATION {heFx} and {choker.Id}'s a plain ANIMATION {chokerFx}"); + if (!ctx.SyntheticData) + { + ctx.Check(heRow is { Animation: null, SurfaceAnimation: "he_ground_effect" } + && chokerRow is { Animation: "scatter_effect", SurfaceAnimation: null }, + $"wep_06's default row binds SURFACE_ANIMATION he_ground_effect and wep_12's a plain ANIMATION scatter_effect"); + } var textures = new TextureArchive(texturesPath); ProjectilePool? pool = null; @@ -1949,20 +2107,20 @@ static string Describe((string Name, Vector3 At, Basis Orient, Basis? Ring)? pla play is { } p ? $"fx={p.Name} Y={p.Orient.Y} ringNormal={(p.Ring is { } r ? (r * ProjectilePool.UpperRingDiscNormal).ToString() : "-")}" : "no play"; var onSlope = Drop(he, origin + new Vector3(0f, 20f, 0f)); - ctx.Check(onSlope is { Name: "he_ground_effect" } s1 && DegreesBetween(s1.Orient.Y, slopeNormal) < 0.5f + ctx.Check(onSlope is { } s1 && s1.Name == heFx && DegreesBetween(s1.Orient.Y, slopeNormal) < 0.5f && DegreesBetween(s1.Orient.Y, Vector3.Up) > 29f, - $"wep_06 into the 30° slope plays he_ground_effect with its Y on the slope normal ({Describe(onSlope)} normal={slopeNormal})"); + $"{he.Id} into the 30° slope plays {heFx} with its Y on the slope normal ({Describe(onSlope)} normal={slopeNormal})"); ctx.Check(onSlope is { } s2 && Mathf.IsEqualApprox(s2.Orient.Determinant(), 1f) && s2.Orient.Y.IsEqualApprox(s2.Orient * Vector3.Up), $"the basis is a pure rotation"); var onFlat = Drop(he, flat.GlobalPosition + new Vector3(0f, 20f, 0f)); - ctx.Check(onFlat is { Name: "he_ground_effect" } f1 && f1.Orient.IsEqualApprox(Basis.Identity), + ctx.Check(onFlat is { } f1 && f1.Name == heFx && f1.Orient.IsEqualApprox(Basis.Identity), $"the same round into flat ground plays it on the fixed axis ({Describe(onFlat)})"); var chokerOnSlope = Drop(choker, origin + new Vector3(0f, 20f, 0f)); - ctx.Check(chokerOnSlope is { Name: "scatter_effect" } c1 && c1.Orient.IsEqualApprox(Basis.Identity), - $"wep_12's plain ANIMATION scatter_effect keeps its fixed axis on the slope ({Describe(chokerOnSlope)})"); + ctx.Check(chokerOnSlope is { } c1 && c1.Name == chokerFx && c1.Orient.IsEqualApprox(Basis.Identity), + $"{choker.Id}'s plain ANIMATION {chokerFx} keeps its fixed axis on the slope ({Describe(chokerOnSlope)})"); ctx.Check(onSlope is { Ring: null } && onFlat is { Ring: null } && chokerOnSlope is { Ring: null }, $"the faithful presentation hands the upper ring no basis on any of the three, so it keeps the decoded fixed axis"); @@ -1993,7 +2151,7 @@ static string Describe((string Name, Vector3 At, Basis Orient, Basis? Ring)? pla // confused. var slanted = new Vector3(-1f, -1f, 0f).Normalized(); var enhancedOnSlope = Drop(he, origin + new Vector3(20f, 20f, 0f), slanted); - ctx.Check(enhancedOnSlope is { Name: "he_ground_effect", Ring: not null } e1 + ctx.Check(enhancedOnSlope is { Ring: not null } e1 && e1.Name == heFx && DegreesBetween(e1.Ring!.Value * discNormal, -slanted) < 15f && DegreesBetween(e1.Ring!.Value * discNormal, Vector3.Up) > 20f && DegreesBetween(e1.Ring!.Value * discNormal, slopeNormal) > 20f, @@ -2039,7 +2197,7 @@ internal static void BurstLightEnvelope(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_06", out var he)) + if (PickFireballRocket(ctx, weapons) is not { } he || DefaultBurst(he) is not { } heFx) { ctx.Check(false, $"wep_06 resolves"); return; @@ -2047,6 +2205,10 @@ internal static void BurstLightEnvelope(TestContext ctx) ctx.Check(EffectCatalogue.IsBurstLight("he_ground_effect") && !EffectCatalogue.IsBurstLight("3040slug_gunhit"), $"the catalogue calls wep_06's he_ground_effect a fireball and a gun hit not one"); + if (!ctx.SyntheticData) + { + ctx.Check(heFx == "he_ground_effect", $"wep_06's default row plays he_ground_effect ({heFx})"); + } // The seeker's ground flare and the flash rocket's detonation carry no fireball. They take a // burst light but neither shimmer nor scorch. var flash = EffectCatalogue.BurstLightShape("flash_effect"); @@ -2094,13 +2256,13 @@ bool Drop() for (int i = 0; i < 120 && plays.Count == 0; i++) live.SimStep(Dt); live.Clear(); - return plays.Contains("he_ground_effect"); + return plays.Contains(heFx); } // The faithful presentation first: it is what every pinned golden renders, and the // able-to-fail control for everything below. lights = new WorldLights(omniParent); - ctx.Check(Drop(), $"the rocket reaches the plate and plays he_ground_effect"); + ctx.Check(Drop(), $"the rocket reaches the plate and plays {heFx}"); lights.Begin(Dt); lights.Commit(viewers); ctx.Same(0, lights.CommittedPositions.Count, @@ -2183,11 +2345,15 @@ internal static void HeatShimmerBurst(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_06", out var he)) + if (PickFireballRocket(ctx, weapons) is not { } he || DefaultBurst(he) is not { } heFx) { ctx.Check(false, $"wep_06 resolves"); return; } + if (!ctx.SyntheticData) + { + ctx.Check(heFx == "he_ground_effect", $"wep_06's default row plays he_ground_effect ({heFx})"); + } const float Dt = 1f / 60f; var origin = new Vector3(1500f, 4000f, 1500f); @@ -2223,12 +2389,12 @@ bool Drop() for (int i = 0; i < 120 && plays.Count == 0; i++) live.SimStep(Dt); live.Clear(); - return plays.Contains("he_ground_effect"); + return plays.Contains(heFx); } // The faithful presentation first: it is what every pinned golden renders, and the // able-to-fail control for everything below. - ctx.Check(Drop(), $"the rocket reaches the plate and plays he_ground_effect"); + ctx.Check(Drop(), $"the rocket reaches the plate and plays {heFx}"); ctx.Check(factory.Shimmer == null, $"ABLE-TO-FAIL CONTROL: the faithful presentation builds no shimmer pool for the same hit"); ctx.Same(0, worldRoot.GetChildCount(), $"and adds no node to the world"); @@ -2435,8 +2601,14 @@ internal static void OrdnanceImpactEffects(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_10", out var beeper) || !weapons.TryGet("wep_11", out var seeker) - || !weapons.TryGet("wep_07", out var flak) || !weapons.TryGet("wep_30", out var gun)) + if (PickWeapon(ctx, weapons, "wep_10", w => w.BeeperTime != null) is not { } beeper + || PickWeapon(ctx, weapons, "wep_11", w => w.BeeperSeeker) is not { } seeker + || PickWeapon(ctx, weapons, "wep_07", w => ProjectilePool.CarriesLockOn(w) && w.DetonationDistance is > 0f + && !ProjectilePool.AircraftDamageDiscarded(w) + && w.ImpactFor(SurfaceRegistry.Default) is { Animation: { } fx } && w.ImpactFor(SurfaceRegistry.Player) is { Animation: { } own } + && fx != own) is not { } flak + || PickWeapon(ctx, weapons, "wep_30", w => w.IsGun + && w.ImpactFor(SurfaceRegistry.Player)?.Animation is { } hit && w.ImpactFor(SurfaceRegistry.Default)?.Animation == hit) is not { } gun) { ctx.Check(false, $"wep_07, wep_10, wep_11 and wep_30 resolve"); return; @@ -2444,21 +2616,35 @@ internal static void OrdnanceImpactEffects(TestContext ctx) // The authored rows this suite performs, read off the data first so a failure below names // the mechanism and not a changed table. - ctx.Check(flak.ImpactFor(SurfaceRegistry.Default) is { Animation: "flak_effect", Sound: "snd_missile_flak" }, - $"wep_07's default row authors flak_effect + snd_missile_flak"); - ctx.Check(flak.ImpactFor(SurfaceRegistry.Player) is { Animation: "flak_effectplayer" }, - $"wep_07's player row authors flak_effectplayer, a name no chapter defines"); + string flakFx = flak.ImpactFor(SurfaceRegistry.Default)?.Animation ?? "-"; + ctx.Check(flak.ImpactFor(SurfaceRegistry.Default) is { Animation: not null } + && flak.ImpactFor(SurfaceRegistry.Player) is { Animation: { } flakOwn } && flakOwn != flakFx, + $"{flak.Id}'s default row authors {flakFx} and its player row another name"); ctx.Check(beeper.ImpactFor(SurfaceRegistry.Default) == null, $"wep_10's default row is named-and-empty (a null row): a burst that strikes nothing plays nothing"); - ctx.Check(beeper.ImpactFor(SurfaceRegistry.Player) is { Animation: "large_fireball", Sound: "snd_missile_beeper" }, - $"wep_10's player row authors large_fireball + snd_missile_beeper"); - ctx.Check(seeker.ImpactFor(SurfaceRegistry.Default) is { Animation: "ballflare.flt", Sound: "snd_missile_seeker" }, - $"wep_11's default row authors ballflare.flt + snd_missile_seeker (the white ground flare)"); - ctx.Check(seeker.ImpactFor(SurfaceRegistry.Player) is { Animation: "large_fireball" }, - $"wep_11's player row authors large_fireball"); - ctx.Check(gun.ImpactFor(SurfaceRegistry.Player) is { Animation: "3040slug_gunhit" } - && gun.ImpactFor(SurfaceRegistry.Default) is { Animation: "3040slug_gunhit" }, - $"wep_30's player and default rows both author 3040slug_gunhit"); + ctx.Check(beeper.ImpactFor(SurfaceRegistry.Player) is { Animation: not null }, + $"{beeper.Id}'s player row authors {beeper.ImpactFor(SurfaceRegistry.Player)?.Animation}, the fireball a direct strike alone plays"); + ctx.Check(seeker.ImpactFor(SurfaceRegistry.Default) is { Animation: not null } + && seeker.ImpactFor(SurfaceRegistry.Player) is { Animation: not null }, + $"{seeker.Id}'s default and player rows each author an ANIMATION"); + if (!ctx.SyntheticData) + { + ctx.Check(flak.ImpactFor(SurfaceRegistry.Default) is { Animation: "flak_effect", Sound: "snd_missile_flak" }, + $"wep_07's default row authors flak_effect + snd_missile_flak"); + ctx.Check(flak.ImpactFor(SurfaceRegistry.Player) is { Animation: "flak_effectplayer" }, + $"wep_07's player row authors flak_effectplayer, a name no chapter defines"); + ctx.Check(beeper.ImpactFor(SurfaceRegistry.Player) is { Animation: "large_fireball", Sound: "snd_missile_beeper" }, + $"wep_10's player row authors large_fireball + snd_missile_beeper"); + ctx.Check(seeker.ImpactFor(SurfaceRegistry.Default) is { Animation: "ballflare.flt", Sound: "snd_missile_seeker" }, + $"wep_11's default row authors ballflare.flt + snd_missile_seeker (the white ground flare)"); + ctx.Check(seeker.ImpactFor(SurfaceRegistry.Player) is { Animation: "large_fireball" }, + $"wep_11's player row authors large_fireball"); + ctx.Check(gun.ImpactFor(SurfaceRegistry.Player) is { Animation: "3040slug_gunhit" } + && gun.ImpactFor(SurfaceRegistry.Default) is { Animation: "3040slug_gunhit" }, + $"wep_30's player and default rows both author 3040slug_gunhit"); + } + + string? gunHit = gun.ImpactFor(SurfaceRegistry.Player)?.Animation; var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stats = PlaneStats.Load(ctx.ZrdrPath, ctx.PlaneName); @@ -2521,8 +2707,8 @@ internal static void OrdnanceImpactEffects(TestContext ctx) // and the DEFAULT row's flak_effect is what it draws there. var flakBurst = Fly(flak, victim, muzzle); float flakToPlane = flakBurst?.At.DistanceTo(victim.WorldPosition) ?? -1f; - ctx.Check(flakBurst is { Name: "flak_effect" }, - $"a flak fusing on its own target draws the DEFAULT row's flak_effect (played={flakBurst?.Name ?? "nothing"})"); + ctx.Check(flakBurst is { } flakPlay && flakPlay.Name == flakFx, + $"a flak fusing on its own target draws the DEFAULT row's {flakFx} (played={flakBurst?.Name ?? "nothing"})"); ctx.Check(flakToPlane > 5f && flakToPlane <= (flak.DetonationDistance ?? 0f) + 1f, $"and the burst is a fuse burst inside DETONATION_DISTANCE, not a contact hit d={flakToPlane:0.#} m"); @@ -2549,14 +2735,14 @@ internal static void OrdnanceImpactEffects(TestContext ctx) var fuselage = victim.WorldPosition + (victim.Collider?.Parts.Where(p => p.Name == "fuselage").Select(p => p.Local.Origin).FirstOrDefault() ?? Vector3.Zero); var onAirframe = Strike(gun, fuselage + new Vector3(0f, 0f, -30f), fuselage); - ctx.Check(onAirframe is { Name: "3040slug_gunhit", Sprites: 0 }, - $"a {gun.Id} round on an aircraft hands the player row's 3040slug_gunhit to the effects runtime and draws no stand-in sprite (played={onAirframe.Name ?? "nothing"} sprites={onAirframe.Sprites})"); + ctx.Check(onAirframe.Name == gunHit && onAirframe.Sprites == 0, + $"a {gun.Id} round on an aircraft hands the player row's {gunHit} to the effects runtime and draws no stand-in sprite (played={onAirframe.Name ?? "nothing"} sprites={onAirframe.Sprites})"); var groundAt = origin + new Vector3(-4000f, 0f, 0f); plate = CombatSuites.Plate("impact-fx-ground", new Vector3(40f, 0.2f, 40f), groundAt); ctx.Host.AddChild(plate); ctx.SyncPhysics(); var onGround = Strike(gun, groundAt + new Vector3(0f, 30f, 0f), groundAt); - ctx.Check(onGround is { Name: "3040slug_gunhit", Sprites: 1 }, + ctx.Check(onGround.Name == gunHit && onGround.Sprites == 1, $"and the same round on the ground hands the default row's gunhit over and still draws its one stand-in sprite (played={onGround.Name ?? "nothing"} sprites={onGround.Sprites})"); // Steps one round from `from` toward `at` until it draws or plays, then reads both. The @@ -2583,16 +2769,16 @@ internal static void OrdnanceImpactEffects(TestContext ctx) textures.Dispose(); } - // --- the seeker's ground flare needs the chapter gamez, where ballflare.flt is BOTH a - // template root and a bound def: the def must win, or a bare 1 m disc stands in. - ctx.WithWorld("C1", collision: false, world => + // --- the seeker's ground flare needs a gamez where ballflare.flt is BOTH a template root + // and a bound def, C1's or the synthetic templates. The def must win, or a bare disc stands in. + EffectStageSuiteHelper.WithAnimSource(ctx, "C1", source => { - var bound = EffectCatalogue.WorldEffectAnimNames(world.Session.Program); - var sub = world.Session.Program.Subset(bound); + var bound = EffectCatalogue.WorldEffectAnimNames(source.Program); + var sub = source.Program.Subset(bound); ctx.Check(sub.ByAnimName("ballflare.flt").Count > 0, $"the world-effects bind carries the ballflare.flt def"); - ctx.Check(world.Gamez.FindByName("ballflare.flt") != null, - $"and C1's gamez carries a same-named template root (the name collision under test)"); + ctx.Check(source.Gamez.FindByName("ballflare.flt") != null, + $"and the gamez carries a same-named template root (the name collision under test)"); var worldTextures = new TextureArchive(texturesPath); ProjectilePool? live = null; @@ -2601,8 +2787,8 @@ internal static void OrdnanceImpactEffects(TestContext ctx) { var plays = new List(); live = new ProjectilePool(worldTextures, null, null, - flyoutGamez: world.Gamez, flyoutScene: world.Session.Builder.Scene, - flyoutAnims: world.Session.Program) + flyoutGamez: source.Gamez, flyoutScene: source.Scene, + flyoutAnims: source.Program) { EffectSink = (name, at, orient, ringOrient, ttl) => plays.Add(name), // The production predicate (AnimRuntime.Handles over the bound subset). @@ -2715,7 +2901,8 @@ internal static void OrdnanceLaunchAxis(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); - if (!weapons.TryGet("wep_13", out var smoker) || smoker.SmokeScreenTime is not { } screenTime) + if (PickWeapon(ctx, weapons, "wep_13", w => w.SmokeScreenTime != null) is not { } smoker + || smoker.SmokeScreenTime is not { } screenTime) { ctx.Check(false, $"wep_13 resolves and carries a SMOKE_SCREEN TIME"); return; @@ -2723,6 +2910,10 @@ internal static void OrdnanceLaunchAxis(TestContext ctx) var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var stockLoadouts = StockLoadouts.Load(); var textures = new TextureArchive(texturesPath); + // The synthetic tree carries the stand-in alone, so the shipped census is the install's. + IReadOnlyList<(string Model, string Display)> airframes = ctx.SyntheticData + ? new[] { (ctx.PlaneName, ctx.PlaneName) } + : MarkerRig.PlayerAirframes; try { // The census: how far off the airframe's own axis any shipped pylon marker sits. @@ -2730,7 +2921,7 @@ internal static void OrdnanceLaunchAxis(TestContext ctx) string worstDisplay = string.Empty; float shippedCantDeg = 0f; int mostPylons = 0; - foreach (var (model, display) in MarkerRig.PlayerAirframes) + foreach (var (model, display) in airframes) { Node3D? plane = null; try @@ -2758,8 +2949,12 @@ internal static void OrdnanceLaunchAxis(TestContext ctx) // ⚠ Every shipped pylon marker is square to its airframe, so the two launch axes agree // on the shipped fit and the fix is a guard rather than a visible change. The salvo // below therefore cants its own markers; nothing else here can tell the two apart. - ctx.Check(shippedCantDeg < 0.5f && mostPylons > 1, - $"no shipped airframe cants a pylon marker (worst {shippedCantDeg:0.00}°); the salvo flies the {worstDisplay}'s {mostPylons} pylons"); + ctx.Check(mostPylons > 1, $"the salvo flies the {worstDisplay}'s {mostPylons} pylons"); + if (!ctx.SyntheticData) + { + ctx.Check(shippedCantDeg < 0.5f && mostPylons > 1, + $"no shipped airframe cants a pylon marker (worst {shippedCantDeg:0.00}°); the salvo flies the {worstDisplay}'s {mostPylons} pylons"); + } var stats = PlaneStats.Load(ctx.ZrdrPath, worstModel); var stock = StockFor(stockLoadouts, worstModel); @@ -2911,12 +3106,12 @@ FlightController BuildRig(int playerIndex, bool isHuman) "a pooled effect copy is re-reset on checkout: the sonic burst played five times over a four-slot pool draws its rings on the fifth play exactly as on the first (BL-406)")] internal static void EffectPoolReset(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { const int slots = 4; const int plays = slots + 1; const string anim = "sonic_ground_effect"; - var stage = StageBurstRoots(ctx, world, anim, slots); + var stage = StageBurstRoots(ctx, source, anim, slots); var runtime = AnimRuntime.ForEffects( AnimRuntime.NewTemplateStage(pooled: true, shown: true, placesCalled: true), 1, new CountingEmitterFactory(), false, SuiteConstants.BurstTtl, @@ -2926,7 +3121,7 @@ internal static void EffectPoolReset(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(anim)); + runtime.Bind(stage, source.Program.Subset(anim)); var slot0 = stage.GetNode("pool0"); var readings = new List>(); // A burst crosses the fade threshold once per ring, and a console line costs about @@ -3075,13 +3270,13 @@ internal static void SonicGroundRing(TestContext ctx) "a pooled effect copy is restored to its SPAWN POSE on checkout: the HE burst played five times over a four-slot pool, overlapping so the fifth play takes the first's still-live copy, launches its debris from the same pose the first did (BL-511)")] internal static void EffectPoolSpawnPose(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { const int slots = 4; const int plays = slots + 1; const int gapFrames = 30; const string anim = "he_ground_effect"; - var stage = StageBurstRoots(ctx, world, anim, slots); + var stage = StageBurstRoots(ctx, source, anim, slots); // A TTL past the whole run, so no instance retires and the wrap lands on a copy the // pool still counts as live. The stock BurstTtl retires each play before play 5. var runtime = AnimRuntime.ForEffects( @@ -3093,7 +3288,7 @@ internal static void EffectPoolSpawnPose(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(anim)); + runtime.Bind(stage, source.Program.Subset(anim)); var slot0 = stage.GetNode("pool0"); var readings = new List>(); var handedOut = new List>(); @@ -3144,10 +3339,14 @@ internal static void EffectPoolSpawnPose(TestContext ctx) // closure against the chapter gamez, the production derivation, so an unresolvable anchor // throws here naming itself) built once per pool slot, each copy hidden, exactly as // WorldEffectsFactory stages them. The caller frees the returned stage. - internal static Node3D StageBurstRoots(TestContext ctx, TestWorld world, string animName, int slots) + internal static Node3D StageBurstRoots(TestContext ctx, TestWorld world, string animName, int slots) => + StageBurstRoots(ctx, AnimSource.Of(world), animName, slots); + + /// + internal static Node3D StageBurstRoots(TestContext ctx, AnimSource source, string animName, int slots) { - var roots = Flight.Airframe.EffectCatalogue.StageRootsFor(world.Session.Program, new[] { animName }, - Session.World.WorldEffectsFactory.StageRootResolver(world.Gamez)); + var roots = Flight.Airframe.EffectCatalogue.StageRootsFor(source.Program, new[] { animName }, + Session.World.WorldEffectsFactory.StageRootResolver(source.Gamez)); ctx.Check(roots.Count > 0, $"{animName}: its call closure's anchor roots derived ({roots.Count}: {string.Join(", ", roots)})"); var stage = new Node3D { Name = $"BurstStage_{animName}" }; @@ -3156,8 +3355,8 @@ internal static Node3D StageBurstRoots(TestContext ctx, TestWorld world, string var pool = new Node3D { Name = $"pool{slot}" }; pool.SetMeta(AnimRuntime.PoolSlotMeta, slot); stage.AddChild(pool); - int built = Session.World.WorldEffectsFactory.BuildEffectStage(world.Gamez, - world.Session.Builder.Scene, pool, roots); + int built = Session.World.WorldEffectsFactory.BuildEffectStage(source.Gamez, + source.Scene, pool, roots); ctx.Same(roots.Count, built, $"{animName}: template roots staged from the chapter gamez (slot {slot})"); foreach (var child in pool.GetChildren()) { @@ -3310,10 +3509,10 @@ private static List RingReadingsIn(Node3D slot) // would leave the ring on its authored axis and report nothing at all. private static void UpperRingPlacement(TestContext ctx) { - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { const string anim = "he_ground_effect"; - var stage = StageBurstRoots(ctx, world, anim, 1); + var stage = StageBurstRoots(ctx, source, anim, 1); var runtime = AnimRuntime.ForEffects( AnimRuntime.NewTemplateStage(pooled: true, shown: true, placesCalled: true), 1, new CountingEmitterFactory(), false, SuiteConstants.BurstTtl, @@ -3325,7 +3524,7 @@ private static void UpperRingPlacement(TestContext ctx) ctx.Host.AddChild(runtime); try { - runtime.Bind(stage, world.Session.Program.Subset(anim)); + runtime.Bind(stage, source.Program.Subset(anim)); var slot0 = stage.GetNode("pool0"); var site = ctx.Camera.GlobalPosition; static float Degrees(Vector3 a, Vector3 b) => @@ -3357,8 +3556,7 @@ static float Degrees(Vector3 a, Vector3 b) => } ctx.Check(handed != null && runtime.PlayEffectAt(anim, site + new Vector3(80f, 0f, 0f), callOrient: handed), $"the enhanced burst played"); - var enhancedNormal = DiscNormalOf(upper); - ctx.Check(enhancedNormal is { } en && Mathf.Min(Degrees(en, -flight), Degrees(-en, -flight)) < 0.5f, + var enhancedNormal = DiscNormalOf(upper); ctx.Check(enhancedNormal is { } en && Mathf.Min(Degrees(en, -flight), Degrees(-en, -flight)) < 0.5f, $"handed one, the drawn disc faces back along the flight (disc={enhancedNormal} against {-flight})"); ctx.Check(ground != null && Degrees(ground.GlobalBasis.Y, Vector3.Up) < 0.5f, $"the ground ring under it is left on its own axis (Y={ground?.GlobalBasis.Y})"); diff --git a/CSVM/src/Testing/PausePreferencesSuites.cs b/CSVM/src/Testing/PausePreferencesSuites.cs index 78eb4eff0..4fcae3b16 100644 --- a/CSVM/src/Testing/PausePreferencesSuites.cs +++ b/CSVM/src/Testing/PausePreferencesSuites.cs @@ -50,6 +50,7 @@ internal static class PausePreferencesSuites internal static void PausePreferencesLeaf(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); @@ -113,6 +114,7 @@ internal static void PausePreferencesLiveOptions(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(MenuLayout.PathUnder(ctx.DataRoot), $"decoded menu layout"); + ctx.RequirePlane("player_bhawk"); var layout = OriginalAvailability.Load(ctx.DataRoot, out var why); ctx.Check(layout != null, $"the install's layout passes the availability check ({why ?? "ok"})"); if (layout == null) diff --git a/CSVM/src/Testing/PauseSheetSuites.cs b/CSVM/src/Testing/PauseSheetSuites.cs index ed0e4dcbd..eba9738b8 100644 --- a/CSVM/src/Testing/PauseSheetSuites.cs +++ b/CSVM/src/Testing/PauseSheetSuites.cs @@ -148,6 +148,7 @@ private static readonly (float X, float Y)[] CampaignStrips = internal static void PauseSheetScreen(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); // Every campaign mission, not the run's own chapter: no world is built here, so the whole // sequence is affordable, and the filmed mission has to be in it whatever chapter is up. @@ -212,6 +213,7 @@ internal static void PauseSheetScreen(TestContext ctx) internal static void InstantActionPauseSheet(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, EscapeDialog.InstantActionFile); var report = new StringBuilder(); var sheets = new List<(string Key, PauseSheet Sheet)>(); diff --git a/CSVM/src/Testing/PauseWorldIconSuites.cs b/CSVM/src/Testing/PauseWorldIconSuites.cs index 6e10b1ef1..443cebeda 100644 --- a/CSVM/src/Testing/PauseWorldIconSuites.cs +++ b/CSVM/src/Testing/PauseWorldIconSuites.cs @@ -62,6 +62,7 @@ internal static class PauseWorldIconSuites internal static void PauseWorldIcons(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); var missions = CampaignSequence.Load(ctx.ZrdrPath); if (MissionAt(missions, BothSeq) is not { } mission) { diff --git a/CSVM/src/Testing/PlaneWobbleSuites.cs b/CSVM/src/Testing/PlaneWobbleSuites.cs index a8a5b4fc6..9918de4c7 100644 --- a/CSVM/src/Testing/PlaneWobbleSuites.cs +++ b/CSVM/src/Testing/PlaneWobbleSuites.cs @@ -4,6 +4,7 @@ using CSVM.Extraction; using CSVM.Flight.Ai; using CSVM.Flight.Airframe; +using CSVM.Flight.Camera; using CSVM.Flight.Weapons; using CSVM.Mech3; using CSVM.Session.InstantAction; @@ -122,8 +123,19 @@ internal static void PlaneWobbleWalk(TestContext ctx) $"the engage reaches the plane node (boosting={rig.Nitro.Boosting}, peak {engagePeak:E2} rad)"); ctx.Check(biggestStep < engagePeak * 0.5f, $"…ramping into each turn rather than wrapping like a sawtooth (biggest tick step {biggestStep:E2} of peak {engagePeak:E2})"); - ctx.Check(gaps.Count >= 4 && gaps.All(g => g is >= 8 and <= 11), - $"…at the ramp law's own rate rather than the authored 4 Hz (gaps [{string.Join(",", gaps)}])"); + // The ramp law's swing off the record's own nitro: (1+k)/(4·f·k) s, k = e^(-damp/2f), + // with the substep and tick quantisation either side. A sawtooth wrapping at the + // authored rate alternates one-tick and whole-period gaps instead. + var nitro = ShakeDefs.Load(ctx.ZrdrPath).Nitro; + float k = nitro is { Frequency: > 0f } n ? Mathf.Exp(-n.Damp / (2f * n.Frequency)) : 0f; + float swingTicks = nitro != null && k > 0f ? (1f + k) / (4f * nitro.Frequency * k) / Dt : 0f; + ctx.Check(swingTicks > 0f && gaps.Count >= 4 && gaps.All(g => g >= swingTicks - 1.5f && g <= swingTicks + 2f), + $"…at the ramp law's own rate for the record's nitro ({swingTicks:0.0} ticks a swing) rather than its authored frequency (gaps [{string.Join(",", gaps)}])"); + if (!ctx.SyntheticData) + { + ctx.Check(gaps.Count >= 4 && gaps.All(g => g is >= 8 and <= 11), + $"…at the ramp law's own rate rather than the authored 4 Hz (gaps [{string.Join(",", gaps)}])"); + } float engageNose = engageTurns.Max(r => Mathf.Max(Mathf.Abs(r.X), Mathf.Abs(r.Y))); ctx.Check(engageNose > 0f, $"…and the engage moves the nose too (peak pitch or yaw {engageNose:E2} rad)"); diff --git a/CSVM/src/Testing/PufferSuites.cs b/CSVM/src/Testing/PufferSuites.cs index fa69ae6e8..caa6565f9 100644 --- a/CSVM/src/Testing/PufferSuites.cs +++ b/CSVM/src/Testing/PufferSuites.cs @@ -216,6 +216,9 @@ internal static void WorldEmitterAmbience(TestContext ctx) internal static void PufferModes(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr effect readers"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "flame_ball.json"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "pufftrails.json"); + ctx.RequireZrdrEntry(SessionPaths.ChapterZrdr(ctx.DataRoot, ctx.Chapter), "speed_cue.json"); var burstState = PufferState.Load(ctx.ZrdrPath, "flame_ball.json", "fierypuffer"); var trailState = PufferState.Load(ctx.ZrdrPath, "pufftrails.json", "smokepuffer"); @@ -1360,6 +1363,8 @@ internal static void PufferUnauthoredCadence(TestContext ctx) internal static void PufferIdleProcessGate(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr effect readers"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "flame_ball.json"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "pufftrails.json"); var burstState = PufferState.Load(ctx.ZrdrPath, "flame_ball.json", "fierypuffer"); var trailState = PufferState.Load(ctx.ZrdrPath, "pufftrails.json", "smokepuffer"); ctx.Check(burstState != null, $"flame_ball.json defines fierypuffer"); @@ -1794,6 +1799,9 @@ internal static void PufferBlendFlag(TestContext ctx) ctx.RequireData(texturePath, $"{ctx.Chapter} texture archive"); ctx.RequireData(ctx.ZrdrPath, $"zrdr effect readers"); using var textures = new TextureArchive(texturePath); + ctx.RequireTexture(textures, "fire101"); + ctx.RequireTexture(textures, "splashbase"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "missile_puffers.json"); // The flagged set is exactly the emissive elements, and the value is a bitfield: fire101 // ships 7 (additive plus both stretch bits) and splashbase ships 3 (both stretch bits and no @@ -1859,6 +1867,7 @@ internal static void PufferFireGlow(TestContext ctx) string texturePath = SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter); ctx.RequireData(texturePath, $"{ctx.Chapter} texture archive"); using var textures = new TextureArchive(texturePath); + ctx.RequireTexture(textures, "fire_f01"); // The premise this arm was written on: no puffer sprite is additive, so blend cannot // separate the fireball from the smoke and the fire flipbook is named instead. @@ -1907,6 +1916,7 @@ internal static void PufferSmokeSun(TestContext ctx) string texturePath = SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter); ctx.RequireData(texturePath, $"{ctx.Chapter} texture archive"); using var textures = new TextureArchive(texturePath); + ctx.RequireTexture(textures, "smoke101"); ctx.Check(MultiMeshEmitterRenderer.IsSmokeSprite("smoke101") && MultiMeshEmitterRenderer.IsSmokeSprite("THICKBLKSMOKE03"), diff --git a/CSVM/src/Testing/SpawnPropsSuites.cs b/CSVM/src/Testing/SpawnPropsSuites.cs index 3bb41e63b..35fa26dc3 100644 --- a/CSVM/src/Testing/SpawnPropsSuites.cs +++ b/CSVM/src/Testing/SpawnPropsSuites.cs @@ -29,7 +29,8 @@ internal static class SpawnPropsSuites "a session-built player and AI aircraft start with their engines already running, as in the original's Instant Action and CM01: the assemblers play the silent, instant spinprops, no definition that sounds snd_propstart (startprops) ever starts, and the player's own-ship audio carries no snd_propstart player while its engine loop sounds")] internal static void SpawnPropsSilent(TestContext ctx) { - const string plane = "player_warhawk"; + // The stand-in on the synthetic tree; the install's subject is the shipped Warhawk. + string plane = ctx.SyntheticData ? ctx.PlaneName : "player_warhawk"; WithSessionRoster(ctx, plane, (roster, player, sounds, soundDefs) => { var ai = SpawnAi(roster, plane, null, new Vector3(1000f, 3000f, 0f)); @@ -60,6 +61,7 @@ internal static void SpawnPropsByDef(TestContext ctx) { const string autogyro = "player_autogyro", warhawk = "player_warhawk"; const string cabbieDef = "autogyro", rotorAnim = "agyro_rotors"; + ctx.RequirePlane(autogyro, warhawk); const float Dt = 1f / 60f; WithSessionRoster(ctx, autogyro, (roster, player, _, _) => { @@ -206,10 +208,10 @@ private static void WithSessionRoster(TestContext ctx, string playerPlane, ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); ctx.RequireData(ctx.SoundsPath, $"sound archive (soundsh)"); - ctx.WithWorld(ctx.Chapter, collision: false, world => + EffectStageSuiteHelper.WithAnimSource(ctx, source => { var planesGamez = GameZ.Load(ctx.PlanesGamezPath); - var textures = new TextureArchive(SessionPaths.ChapterTextures(ctx.DataRoot, world.Chapter)); + var textures = new TextureArchive(SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter)); using var sounds = new SoundArchive(ctx.SoundsPath); var soundDefs = SoundDefs.Load(ctx.ZrdrPath); var pool = new ProjectilePool(textures, null, null); @@ -241,9 +243,9 @@ private static void WithSessionRoster(TestContext ctx, string playerPlane, new FlightWorldBindings { Projectiles = pool, - Gamez = world.Gamez, - WorldScene = world.Session.Builder.Scene, - CrashProgram = world.Session.Program, + Gamez = source.Gamez, + WorldScene = source.Scene, + CrashProgram = source.Program, Sounds = sounds, SoundDefs = soundDefs, SoundGroups = SoundDefs.LoadGroups(ctx.ZrdrPath), diff --git a/CSVM/src/Testing/SuiteCatalog.cs b/CSVM/src/Testing/SuiteCatalog.cs index 6b04b2c1e..dbfbb83db 100644 --- a/CSVM/src/Testing/SuiteCatalog.cs +++ b/CSVM/src/Testing/SuiteCatalog.cs @@ -32,10 +32,225 @@ public static class SuiteCatalog "music-states", }; - /// The suite names a tier holds, or null when no tier carries that name (which a - /// selector must treat as a miss, not as an empty selection). - public static IReadOnlyList? Tier(string name) => - name.Equals("quick", StringComparison.OrdinalIgnoreCase) ? QuickTier : null; + // The suites `--run-tests=tier:ci` runs and CI requires to pass. A suite earns membership by + // passing with no extraction but the --synthetic-data tree, headless, with no IPv6 loopback + // and no OS shell. A listed suite that loses its input must go red, so this tier counts a skip + // as a failure. A suite joins when it stops needing the install. One name per line, sorted; + // the rule is in docs/tooling.md. + public static readonly IReadOnlyList CiTier = new[] + { + "ai-actor", + "ai-airframe-pool", + "ai-engine-listeners", + "ai-engine-rearm", + "ai-gunnery", + "ai-modes", + "ai-pursues-structure", + "ai-target-rescore", + "ai-voice", + "ai-weapon-emitters", + "aim-assist", + "air-to-air", + "aircraft-first-targeting", + "anim-call-start-order", + "anim-clock-realtime", + "audio-buses", + "audio-levels-launch", + "bindings-launch-load", + "bindings-prompt-device", + "blast-curve-cover-cap", + "blast-neighbor-shape", + "build-stamp-icons", + "burst-light-envelope", + "campaign-coop-cutscene-fullscreen", + "campaign-layout-core", + "carried-state-silent", + "carried-turrets", + "chase-trail", + "cinema-skip-pad", + "cockpit-interior", + "cockpit-overlay-pass", + "cockpit-panel-staging", + "cockpit-sun-bearing", + "damage-stage-slots", + "damage-staging-pool", + "damage-template-freed-anchor", + "damage-template-pool", + "danger-zone-photograph-fill", + "death-camera", + "disabling-hits", + "display-det-guard", + "display-render-scale", + "display-shadow-quality", + "effect-pool-reset", + "effect-pool-spawn-pose", + "effect-template-mesh", + "emitter-host-deactivation", + "empty-stage-net", + "enet-load-stall", + "enet-transport", + "engine-cockpit-pitch", + "engine-damage-phases", + "engine-note", + "engine-voice-duck", + "exhaust-smoke", + "exhaust-smoke-ai", + "extraction-picker", + "extraction-screen", + "fade-walk-bound", + "first-person-condition", + "flight-input-handback", + "flight-live-respawn-gate", + "flight-mouse-capture", + "flight-mouse-scheme", + "flight-mouse-scheme-live", + "flight-roster-transaction", + "flight-telemetry-gate", + "flyby-camera", + "forward-rotation", + "gasbag-ordnance-gate", + "gltf-export", + "graphics-switch-cover", + "graze-bounce", + "ground-contact-core", + "heat-shimmer", + "hostile-marker-hud", + "hud-auto-dock-line", + "hud-crash-prompt", + "hud-kill-line", + "impact-orientation", + "incoming-fire-cues", + "inert-aircraft", + "instant-action", + "lan-discovery", + "launch-direction-cache", + "launch-velocity-decay", + "menu-backdrop", + "menu-controls-seats", + "menu-coop-door", + "menu-free-flight-journey", + "menu-hangar-journey-core", + "menu-host-address", + "menu-host-pointer", + "menu-host-tracer", + "menu-instant-action-journey-core", + "menu-join-board", + "menu-launch-return", + "menu-net-door", + "menu-original-boot", + "menu-original-builtin-host", + "menu-original-cheats", + "menu-original-connection", + "menu-original-controls", + "menu-original-coop-boot", + "menu-original-coop-film", + "menu-original-coop-flow", + "menu-original-lobby", + "menu-original-lobby-teams", + "menu-original-outlaw-list", + "menu-original-version", + "menu-player-setup-journey", + "menu-player-setup-seats", + "menu-zone-layout", + "motor-acceleration", + "music-states", + "net-aircraft-replication", + "net-bot-respawn", + "net-capture-the-flag", + "net-combat-events", + "net-custom-planes", + "net-cutscene-skip-episode", + "net-enet-join", + "net-flight-chat", + "net-guest-keymap", + "net-kill-line", + "net-match-state", + "net-pause-overlay", + "net-rearm-deathmatch", + "net-relay-star", + "net-seats", + "net-soak", + "net-spawn-rotation", + "net-start-together", + "net-team-deathmatch", + "net-two-session", + "net-versus-host-left", + "net-versus-lives", + "nodelab-visibility", + "options-difficulty-launch", + "options-targeting-launch", + "options-view-launch", + "ordnance-end-conditions", + "ordnance-guidance", + "ordnance-impact-effects", + "ordnance-launch-axis", + "pause-board", + "pause-preferences-live-options", + "perf-hud-layout", + "plane-shader-reuse", + "plane-wobble-walk", + "puffer-distance-fade", + "puffer-draw-order", + "puffer-idle-process-gate", + "puffer-priority-size", + "puffer-wind", + "ranked-pool-carried-turret-dedup", + "remote-airframe", + "render-poses", + "repeat-call-slots", + "results-board-shell", + "scene-build-throw-frees", + "scorch-decals", + "session-start-cover", + "shootable-flyout", + "smoke-screen", + "spawn-props-by-def", + "spawn-props-silent", + "splitscreen-listeners", + "start-state-swap-pool", + "stick-discrete-buttons", + "sun-orientation", + "target-flag", + "target-input", + "target-ref", + "target-selection", + "targeting-candidates", + "team-model", + "tex-dropin", + "turret-death-effect-world-anchor", + "turret-grouped-site-sightline", + "turret-own-mount-sightline", + "versus-local-bot-graze", + "versus-spawn-empty-stage", + "versus-spawn-net-table", + "versus-spawn-rotation", + "voice-runtime", + "wait-for-completion", + "warhawk-torpedo-run", + "weapon-selector-input", + "weapons-fire", + "weather-cockpit-whiteout", + "wind-streaks", + "world-lights-enhanced-budget", + "world-lights-nearest-viewer", + "world-query-reuse", + "world-sound-falloff", + }; + + /// The tier carrying that name, or null when none does (which a selector must treat + /// as a miss, not as an empty selection). + public static SuiteTier? Tier(string name) + { + if (name.Equals("quick", StringComparison.OrdinalIgnoreCase)) + { + return new SuiteTier(QuickTier, SkipFails: false); + } + if (name.Equals("ci", StringComparison.OrdinalIgnoreCase)) + { + return new SuiteTier(CiTier, SkipFails: true); + } + return null; + } /// Every marked body, ordered by name. ⚠ Keep this order deterministic and /// machine-independent: breaks balancer ties on registry position and @@ -87,3 +302,8 @@ private static Action Body(MethodInfo method, SuiteAttribute mark, return (Action)method.CreateDelegate(typeof(Action)); } } + +/// A checked-in tier: the suite names it holds, and whether a member that SKIPs fails the +/// run. The rule rides on the tier, not the harness, so only a selector naming such a tier changes +/// what a skip means. Every other selector keeps SKIP as "the data was not there". +public sealed record SuiteTier(IReadOnlyList Suites, bool SkipFails); diff --git a/CSVM/src/Testing/SuiteConstants.cs b/CSVM/src/Testing/SuiteConstants.cs index e64d7d8b9..d930cf7c7 100644 --- a/CSVM/src/Testing/SuiteConstants.cs +++ b/CSVM/src/Testing/SuiteConstants.cs @@ -130,4 +130,17 @@ internal static AircraftAssemblyResources AircraftResources( Textures = textures, Shakes = ShakeDefs.Load(ctx.ZrdrPath), }; + + /// One float read raw off the zrdr scope's player.json, inside + /// when one is named. Null when the record does not author it. A + /// check compares it with the typed loader's field, so the field answers to its record on any + /// tree rather than to one install's figure. + internal static float? PlayerGlobal(string zrdrPath, string key, string? block = null) + { + if (Zrdr.LoadFile(zrdrPath, "player.json")[0] is not List list) + return null; + var player = ZrdrDict.FromAlternating(list); + var scope = block == null ? player : player.Dict(block); + return scope != null && scope.TryFloat(key, out float value) ? value : null; + } } diff --git a/CSVM/src/Testing/TargetingCandidateSuites.cs b/CSVM/src/Testing/TargetingCandidateSuites.cs index f77544a3a..2e30e76dc 100644 --- a/CSVM/src/Testing/TargetingCandidateSuites.cs +++ b/CSVM/src/Testing/TargetingCandidateSuites.cs @@ -305,6 +305,7 @@ internal static void GasbagOrdnanceGate(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane("player_warhawk"); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); @@ -453,6 +454,7 @@ internal static void AircraftFirstTargeting(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); @@ -659,6 +661,7 @@ internal static void AiTargetRescore(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane("player_bhawk"); const string PlaneNode = "player_bhawk"; const string WingmanDef = "wbloodhawk"; @@ -825,6 +828,7 @@ internal static void RankedPoolCarriedTurretDedup(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); diff --git a/CSVM/src/Testing/TargetingSuites.cs b/CSVM/src/Testing/TargetingSuites.cs index 030f3961a..b074bf3d0 100644 --- a/CSVM/src/Testing/TargetingSuites.cs +++ b/CSVM/src/Testing/TargetingSuites.cs @@ -766,6 +766,7 @@ internal static void TargetPoolModel(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); var turretDefs = TurretDefs.Load(ctx.ZrdrPath); // Private: the assertions read every site as ia1.gw leaves it and then the five aaguns diff --git a/CSVM/src/Testing/TestHarness.cs b/CSVM/src/Testing/TestHarness.cs index c40f83eee..7311578ae 100644 --- a/CSVM/src/Testing/TestHarness.cs +++ b/CSVM/src/Testing/TestHarness.cs @@ -85,8 +85,8 @@ public static IReadOnlyList All /// Runs the suites selects (empty = all), prints the table, /// writes .scratch/test-report.json, and returns the process exit code: 0 when nothing - /// failed, 1 otherwise. A skipped suite is not a failure; a selector term that matched nothing - /// is, and nothing runs in that case. + /// failed, 1 otherwise. A skipped suite is a failure only when names + /// it. A selector term that matched nothing is a failure, and nothing runs in that case. public static int Run(TestContext ctx, string filter) { // Numbers in a committed report must read the same on every machine. @@ -99,8 +99,13 @@ public static int Run(TestContext ctx, string filter) return 1; } var selected = Select(All, terms, out var unmatched); + var skipFails = SkipFailures(terms); var results = new List(); Log.Info("test", $"run-tests suites={selected.Count}/{All.Count} filter='{filter}' chapter={ctx.Chapter} mission={ctx.Mission}"); + if (ctx.SyntheticData) + { + Log.Warn("test", $"run-tests reads SYNTHETIC data dataRoot={ctx.DataRoot}: a PASS here proves a code path on invented records, never the game's data"); + } if (unmatched.Count > 0) { Log.Error("test", $"run-tests selector matched nothing: {string.Join(", ", unmatched)}; registered: {string.Join(", ", All.Select(s => s.Name))}"); @@ -180,6 +185,15 @@ public static int Run(TestContext ctx, string filter) Log.Error("test", $"FAIL {leak}"); status = SuiteStatus.Fail; } + // After the orphan check, which a skipped body is exempt from. The row keeps the skip + // reason as its detail, so the report names the input that went missing. + if (status == SuiteStatus.Skip && skipFails.TryGetValue(suite.Name, out string? refusing)) + { + string refused = $"skipped, and {refusing} counts a skip as a failure: {detail}"; + ctx.Failures.Add(refused); + Log.Error("test", $"FAIL {suite.Name} {refused}"); + status = SuiteStatus.Fail; + } double wallSeconds = watch.Elapsed.TotalSeconds; double buildSeconds = ctx.WorldBuildSeconds; double disposalSeconds = ctx.DisposalSeconds; @@ -264,7 +278,7 @@ public static int Run(TestContext ctx, string filter) Log.Raw(FormatTable(results, screen, logPath)); WriteReport(ctx, results, screen, logPath, phaseTotals, filter, plan); string errors = screen == null ? "unscreened" : screen.Ok ? "clean" : "UNEXPECTED"; - Log.Info("test", $"run-tests pass={pass} fail={fail} skip={skip} errors={errors}"); + Log.Info("test", $"run-tests pass={pass} fail={fail} skip={skip} errors={errors}{(ctx.SyntheticData ? " data=SYNTHETIC" : "")}"); return fail > 0 || screenFailed ? 1 : 0; } @@ -305,6 +319,30 @@ public static IReadOnlyList Select(IReadOnlyList suites, string sp return suites.Where(s => wanted.Contains(s.Name)).ToList(); } + /// The suites whose SKIP fails a run of , each mapped to the + /// tier: term that refuses it. They are the members of every named tier carrying + /// . A suite reached only by another term keeps SKIP as a + /// non-failure. Pure, like . + public static IReadOnlyDictionary SkipFailures(string spec) + { + const string tier = "tier:"; + var refused = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string raw in spec.Split(',')) + { + string term = raw.Trim(); + if (!term.StartsWith(tier, StringComparison.OrdinalIgnoreCase) + || SuiteCatalog.Tier(term[tier.Length..]) is not { SkipFails: true } named) + { + continue; + } + foreach (string name in named.Suites) + { + refused.TryAdd(name, term); + } + } + return refused; + } + /// Classifies a run's log lines: how many engine error lines there were, how many the /// allowlist covers, which are unknown, and which allowed pattern went over its cap. Pure, no /// Godot API, no file access, so it is unit-testable outside the engine. @@ -358,6 +396,10 @@ public static StderrScreen Screen(IEnumerable lines) }; } + /// How many engine error lines this run's log holds so far, or null with no log. For a + /// suite that brackets its own work. + internal static int? EngineErrorsSoFar() => ScreenEngineLog(out _)?.Total; + // Narrows an already-valid selection to one shard and tags the context, so every artifact this // process writes lands under .scratch// instead of over a sibling shard's. private static ShardPlan PlanShard(TestContext ctx, ShardSpec? shard, ref IReadOnlyList selected) @@ -391,12 +433,12 @@ private static List MatchTerm(IReadOnlyList suites, string term) } if (term.StartsWith(tier, StringComparison.OrdinalIgnoreCase)) { - var names = SuiteCatalog.Tier(term[tier.Length..]); - if (names == null) + var named = SuiteCatalog.Tier(term[tier.Length..]); + if (named == null) { return new List(); } - var set = new HashSet(names, StringComparer.OrdinalIgnoreCase); + var set = new HashSet(named.Suites, StringComparer.OrdinalIgnoreCase); return suites.Where(s => set.Contains(s.Name)).ToList(); } return suites.Where(s => s.Name.Contains(term, StringComparison.OrdinalIgnoreCase)).ToList(); @@ -548,6 +590,7 @@ private static void WriteReport(TestContext ctx, List results, json.AppendLine($" \"chapter\": {Quote(ctx.Chapter)},"); json.AppendLine($" \"mission\": {Quote(ctx.Mission)},"); json.AppendLine($" \"dataRoot\": {Quote(ctx.DataRoot)},"); + json.AppendLine($" \"syntheticData\": {(ctx.SyntheticData ? "true" : "false")},"); json.AppendLine($" \"passed\": {results.Count(r => r.Status == SuiteStatus.Pass)},"); json.AppendLine($" \"failed\": {results.Count(r => r.Status == SuiteStatus.Fail)},"); json.AppendLine($" \"skipped\": {results.Count(r => r.Status == SuiteStatus.Skip)},"); @@ -779,8 +822,16 @@ public sealed class TestContext // see DecodeCache for what it holds and the read-only contract that binds every user. private readonly DecodeCache _decode = new(); + // The synthetic planes archive's node names, read once by RequirePlane and only under the switch. + private HashSet? _syntheticPlaneNodes; + public required string RepoRoot { get; init; } public required string DataRoot { get; init; } + + /// Whether is the invented tree --synthetic-data writes, + /// read off the tree's own stamp. The run's log and report say so. + public bool SyntheticData { get; init; } + public required string Chapter { get; init; } public required string Mission { get; init; } public required string ZrdrPath { get; init; } @@ -851,6 +902,10 @@ public sealed class TestContext /// hits happened rather than only that the wall time moved. internal (int Hits, int Misses) DecodeCounts => (_decode.Hits, _decode.Misses); + /// The run's decode store. A suite that builds a whole GameSession hands it to + /// the launcher context, so its sessions share the decodes this run's worlds made. + internal DecodeCache Decode => _decode; + /// Reads every positional sound level and cull at the data's own RANGE radii /// until the returned handle is disposed, whatever factor the session carries. A suite that /// pins the decoded law's numbers has to say which factor it reads them at. The shipped @@ -905,6 +960,52 @@ public void RequireData(string path, FormattableString what) } } + /// On the synthetic tree, skips a suite whose body reads a shipped texture by + /// that the invented archive does not carry. ⚠ Never skip on a real + /// extraction: a shipped name missing there is a broken tree, which the body reports. + public void RequireTexture(TextureArchive textures, string name) + { + if (SyntheticData && textures.FindImage(name) == null) + { + throw new SuiteSkippedException($"the synthetic texture archive carries no {name}"); + } + } + + /// Skips a suite whose body reads the reader file when the + /// zrdr archive at lacks it. The archive may be a ZIP or its unpacked + /// folder. Checked on every tree, like . A suite names only entries every + /// install ships, so on a real extraction the gate passes wherever the body would have run. + public void RequireZrdrEntry(string zrdrPath, string fileName) + { + RequireData(zrdrPath, $"zrdr archive for {fileName}"); + if (!Zrdr.HasFile(zrdrPath, fileName)) + { + throw new SuiteSkippedException($"{fileName} not found in {zrdrPath}"); + } + } + + /// On the synthetic tree, skips a suite whose body needs a shipped airframe, by node + /// name, that the invented planes archive lacks. ⚠ Never skip on a real extraction: every + /// install carries every shipped airframe, so a miss there is a broken tree the body reports. + public void RequirePlane(params string[] nodeNames) + { + if (!SyntheticData) + { + return; + } + + RequireData(PlanesGamezPath, $"planes gamez"); + _syntheticPlaneNodes ??= new HashSet( + GameZ.Load(PlanesGamezPath).Nodes.Select(n => n.Name), StringComparer.Ordinal); + foreach (string name in nodeNames) + { + if (!_syntheticPlaneNodes.Contains(name)) + { + throw new SuiteSkippedException($"the synthetic planes gamez carries no {name}"); + } + } + } + /// Leaves a suite's full report in the scratch folder, so a failure is diagnosable /// without re-running the equivalent --dump-* tool by hand. Absolute path, inside /// .scratch/, where every suite artifact belongs. The one exception is a suite proving diff --git a/CSVM/src/Testing/TurretHullBlockSuites.cs b/CSVM/src/Testing/TurretHullBlockSuites.cs index 1dfbdf8a4..52ec89cc7 100644 --- a/CSVM/src/Testing/TurretHullBlockSuites.cs +++ b/CSVM/src/Testing/TurretHullBlockSuites.cs @@ -36,6 +36,7 @@ internal static void TurretHullBlocksOwnFire(TestContext ctx) { ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); diff --git a/CSVM/src/Testing/TurretMountSightlineSuites.cs b/CSVM/src/Testing/TurretMountSightlineSuites.cs index 562fd75dd..1a5e6379b 100644 --- a/CSVM/src/Testing/TurretMountSightlineSuites.cs +++ b/CSVM/src/Testing/TurretMountSightlineSuites.cs @@ -32,6 +32,7 @@ internal static class TurretMountSightlineSuites internal static void OwnMountIsNotCover(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); @@ -110,6 +111,7 @@ internal static void OwnMountIsNotCover(TestContext ctx) internal static void GroupedSiteExcludesItsOwnRigOnly(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); diff --git a/CSVM/src/Testing/TurretStructureSuites.cs b/CSVM/src/Testing/TurretStructureSuites.cs index b83be846e..1fb0f2b00 100644 --- a/CSVM/src/Testing/TurretStructureSuites.cs +++ b/CSVM/src/Testing/TurretStructureSuites.cs @@ -39,6 +39,7 @@ internal static void TurretStructureTargets(TestContext ctx) CheckSlotRule(ctx); CheckRegistryRule(ctx); ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, "ai.json"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, ShipChapter); ctx.RequireData(texturesPath, $"{ShipChapter} textures"); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); diff --git a/CSVM/src/Testing/VersusSpawnSuites.cs b/CSVM/src/Testing/VersusSpawnSuites.cs index 9a7c5d5d8..cc812a4c7 100644 --- a/CSVM/src/Testing/VersusSpawnSuites.cs +++ b/CSVM/src/Testing/VersusSpawnSuites.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using CSVM.Extraction; using CSVM.Flight.Airframe; using CSVM.Flight.Camera; @@ -8,6 +9,7 @@ using CSVM.Mech3; using CSVM.Session.Roster; using CSVM.Spec; +using CSVM.Tooling; using CSVM.Utils; using Godot; @@ -39,15 +41,22 @@ internal static void VersusSpawnRotationSeam(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, ctx.Chapter); ctx.RequireData(texturesPath, $"{ctx.Chapter} textures"); string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, "IA1"); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/IA1 zrdr"); - var spawns = SpawnPoints.LoadIa(missionZrdr, Scenario); + // A root with no IA1 walks the list a --vs launch on the empty stage hands the same + // rotation, the stage's code-built spawn ring. + bool onStage = !System.IO.File.Exists(missionZrdr) && !System.IO.Directory.Exists(missionZrdr); + string listName = onStage ? "the empty stage's spawn ring" : $"{ctx.Chapter}/IA1"; + if (onStage) + ctx.Note($"spawn list: {listName}, the data root carries no {ctx.Chapter}/IA1"); + var spawns = onStage + ? new SpawnPicker(SessionSpec.Parse(new[] { "--vs", "--stage=empty" })).LoadSpawnList(missionZrdr, Scenario) + : SpawnPoints.LoadIa(missionZrdr, Scenario); if (spawns is not { Count: >= 4 }) { - throw new SuiteSkippedException($"{ctx.Chapter}/IA1 authors no usable {Scenario} spawn list"); + throw new SuiteSkippedException($"{listName} authors no usable {Scenario} spawn list"); } ctx.Check(spawns.Count >= 4, - $"{ctx.Chapter}/IA1 ships {spawns.Count} {Scenario} spawns for the rotation to walk"); + $"{listName} ships {spawns.Count} {Scenario} spawns for the rotation to walk"); var textures = new TextureArchive(texturesPath); var pool = new ProjectilePool(textures, null, null); @@ -149,21 +158,24 @@ internal static void VersusSpawnRotationSeam(TestContext ctx) + "the identical launch without --vs still reads no table")] internal static void VersusNetSpawnTable(TestContext ctx) { - string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, ctx.Chapter, MpMission); - ctx.RequireData(missionZrdr, $"{ctx.Chapter}/{MpMission} zrdr"); + // The synthetic tree carries no MP map, so it reads the invented PROBE1 scope's table. + string chapter = ctx.SyntheticData ? SyntheticMission.Chapter : ctx.Chapter; + string mission = ctx.SyntheticData ? SyntheticMission.Mission : MpMission; + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, chapter, mission); + ctx.RequireData(missionZrdr, $"{chapter}/{mission} zrdr"); var spec = SessionSpec.Parse(new[] { - "--vs", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", "--players=4", "--spawn=0", + "--vs", $"--chapter={chapter}", $"--mission={mission}", "--players=4", "--spawn=0", }); var picker = new SpawnPicker(spec); var table = picker.LoadSpawnList(missionZrdr, spec.Scenario); if (table is not { Count: > 0 }) { - throw new SuiteSkippedException($"{ctx.Chapter}/{MpMission} authors no net.zrd table"); + throw new SuiteSkippedException($"{chapter}/{mission} authors no net.zrd table"); } ctx.Check(picker.NetSpawns && table.Count == SpawnPoints.NetBlock, - $"the picker answers {MpMission} with the net table's free-for-all block: {table.Count} entries, net={picker.NetSpawns}"); + $"the picker answers {mission} with the net table's free-for-all block: {table.Count} entries, net={picker.NetSpawns}"); var starts = picker.ChooseStarts(table, missionZrdr, picker.ChooseSpawnBase(table), 4); var seen = new List(); @@ -192,12 +204,100 @@ internal static void VersusNetSpawnTable(TestContext ctx) // campaign mission's unread placeholder table out of every other launch. var flyPicker = new SpawnPicker(SessionSpec.Parse(new[] { - "--fly", $"--chapter={ctx.Chapter}", $"--mission={MpMission}", + "--fly", $"--chapter={chapter}", $"--mission={mission}", })); ctx.Check(flyPicker.LoadSpawnList(missionZrdr, Scenario) == null && !flyPicker.NetSpawns, $"ABLE-TO-FAIL CONTROL: the same mission without --vs reads no table and falls back"); - ctx.Note($"{ctx.Chapter}/{MpMission}: {table.Count}-entry free-for-all block, seats on entries {string.Join(", ", seen)}"); + ctx.Note($"{chapter}/{mission}: {table.Count}-entry free-for-all block, seats on entries {string.Join(", ", seen)}"); + } + + [Suite("versus-spawn-empty-stage", + "Dogfight on --stage=empty opens on the stage's own code-built spawn ring: the picker " + + "answers with it in place of a net.zrd table, every entry sits on the ring at the stage's " + + "spawn altitude with its nose aimed in, two seats open on opposite entries and four on a " + + "compass cross at the multiplayer opening state, a team match opens each team on its own " + + "block at its own base, an explicit --pos still wins, and the stage without --vs still " + + "reads no table")] + internal static void VersusEmptyStageRing(TestContext ctx) + { + var spec = SessionSpec.Parse(new[] { "--vs", "--stage=empty", "--players=4", "--spawn=0" }); + var picker = new SpawnPicker(spec); + var table = picker.LoadSpawnList("", spec.Scenario); + ctx.Check(spec.EmptyStage && spec.SpawnAt == null && picker.NetSpawns + && table is { Count: EmptyStage.SpawnRingEntries }, + $"the picker answers an empty-stage Dogfight with the stage's ring: {table?.Count ?? 0} entries, net={picker.NetSpawns}, override={spec.SpawnAt?.ToString() ?? "none"}"); + if (table == null) + { + return; + } + + // Aimed in: the nose's bearing is the skew away from the bearing to the origin, no more. + float cosSkew = Mathf.Cos(Mathf.DegToRad(EmptyStage.SpawnRingSkewDeg + 0.5f)); + var bad = new List(); + for (int i = 0; i < table.Count; i++) + { + var p = table[i].Position; + var inward = new Vector3(-p.X, 0f, -p.Z).Normalized(); + var nose = table[i].Forward with { Y = 0f }; + bool onRing = Mathf.Abs(new Vector2(p.X, p.Z).Length() - EmptyStage.SpawnRingRadius) < 0.5f + && Mathf.IsEqualApprox(p.Y, EmptyStage.SpawnAltitude); + if (!onRing || nose.Normalized().Dot(inward) < cosSkew) + { + bad.Add(i); + } + } + + ctx.Check(bad.Count == 0, + $"every entry sits {EmptyStage.SpawnRingRadius:0} m out at {EmptyStage.SpawnAltitude:0} m with its nose within {EmptyStage.SpawnRingSkewDeg:0} degrees of the origin (off: {string.Join(", ", bad)})"); + + var two = picker.ChooseStarts(table, "", picker.ChooseSpawnBase(table), 2); + float apart = two[0].Pos.DistanceTo(two[1].Pos); + ctx.Check(apart > EmptyStage.SpawnRingRadius * 1.99f, + $"two seats open on opposite entries, {apart:0} m apart"); + var four = picker.ChooseStarts(table, "", picker.ChooseSpawnBase(table), 4); + float closest = float.MaxValue; + for (int a = 0; a < four.Count; a++) + { + for (int b = a + 1; b < four.Count; b++) + { + closest = Mathf.Min(closest, four[a].Pos.DistanceTo(four[b].Pos)); + } + } + + ctx.Check(closest > EmptyStage.SpawnRingRadius * 1.41f, + $"four seats open on a compass cross, the closest pair {closest:0} m apart"); + ctx.Check(Mathf.IsEqualApprox(four[0].ThrottleFrac, SpawnPoints.MultiplayerThrottleFrac) + && Mathf.IsEqualApprox(four[0].SpeedMps, SpawnPoints.MultiplayerSpeedMps), + $"on the original's multiplayer opening state: throttle={four[0].ThrottleFrac:0.00} speed={four[0].SpeedMps:0.#}m/s"); + + // A team match takes the whole table and opens each team on its own block, at its own base. + var teams = new[] { 1, 2, 1, 2 }; + var teamPicker = new SpawnPicker(spec) { SeatTeams = teams }; + var teamTable = teamPicker.LoadSpawnList("", spec.Scenario); + teamPicker.PlanTeams(teamTable, 0); + ctx.Check(teamTable is { Count: (EmptyStage.TeamBlockCount + 1) * SpawnPoints.NetBlock } + && teamPicker.SeatEntries is [16, 32, 17, 33], + $"a team match walks team n's block n of the whole {teamTable?.Count ?? 0}-entry table ({string.Join(", ", teamPicker.SeatEntries ?? Array.Empty())})"); + var teamStarts = teamPicker.ChooseStarts(teamTable, "", 0, teams.Length); + var offBase = Enumerable.Range(0, teams.Length).Where(seat => + (teamStarts[seat].Pos with { Y = 0f }).DistanceTo(EmptyStage.TeamBase(teams[seat])) > EmptyStage.TeamBlockSpacing).ToList(); + ctx.Check(offBase.Count == 0 && teamStarts[0].Pos.DistanceTo(teamStarts[1].Pos) > EmptyStage.TeamBaseRadius, + $"and four seats stand at their own team's base, the two teams across the origin (off base: {string.Join(", ", offBase)})"); + + var placed = SessionSpec.Parse(new[] { "--vs", "--stage=empty", "--players=2", "--pos=100,400,0" }); + var placedPicker = new SpawnPicker(placed); + var placedTable = placedPicker.LoadSpawnList("", placed.Scenario); + var (pos, _) = placedPicker.ChooseSpawn(placedTable, "", 0, 0, ""); + ctx.Check(pos.IsEqualApprox(new Vector3(100f, 400f, 0f)), + $"an explicit --pos still beats the ring ({pos.X:0},{pos.Y:0},{pos.Z:0})"); + + // ABLE-TO-FAIL CONTROL: a flight on the stage keeps the single pose over the origin. + var fly = SessionSpec.Parse(new[] { "--fly", "--stage=empty" }); + var flyPicker = new SpawnPicker(fly); + ctx.Check(flyPicker.LoadSpawnList("", fly.Scenario) == null && !flyPicker.NetSpawns + && fly.SpawnAt == new Vector3(0f, EmptyStage.SpawnAltitude, 0f), + $"ABLE-TO-FAIL CONTROL: the stage without --vs reads no table and starts over the origin"); } // The placement closure GameSession installs: the living field read fresh at the respawn, so a diff --git a/CSVM/src/Testing/WarhawkTorpedoRunSuites.cs b/CSVM/src/Testing/WarhawkTorpedoRunSuites.cs index 1fc4a2397..7e1c14a14 100644 --- a/CSVM/src/Testing/WarhawkTorpedoRunSuites.cs +++ b/CSVM/src/Testing/WarhawkTorpedoRunSuites.cs @@ -34,6 +34,7 @@ internal static void WarhawkTorpedoRun(TestContext ctx) ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane("player_warhawk"); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); var weapons = WeaponDefs.Load(ctx.ZrdrPath, null); diff --git a/CSVM/src/Testing/WingWalkCameraSuites.cs b/CSVM/src/Testing/WingWalkCameraSuites.cs index 0b08ca703..7e6a2a687 100644 --- a/CSVM/src/Testing/WingWalkCameraSuites.cs +++ b/CSVM/src/Testing/WingWalkCameraSuites.cs @@ -88,6 +88,7 @@ internal static class WingWalkCameraSuites internal static void WingWalkCamera(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); var mission = MissionOf(CampaignSequence.Load(ctx.ZrdrPath), Cm02Seq) ?? throw new SuiteSkippedException($"cm_sequence carries no story position {Cm02Seq}"); diff --git a/CSVM/src/Testing/WingmanFitSuites.cs b/CSVM/src/Testing/WingmanFitSuites.cs index ac5ae1744..38010aa6a 100644 --- a/CSVM/src/Testing/WingmanFitSuites.cs +++ b/CSVM/src/Testing/WingmanFitSuites.cs @@ -38,6 +38,7 @@ internal static class WingmanFitSuites internal static void AWingmanFliesItsPickedFit(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane(FuryNode, BalmoralNode); var defs = VehicleDefs.Load(ctx.ZrdrPath); WingmanDefCensus(ctx, defs); diff --git a/CSVM/src/Testing/WingmanSuites.cs b/CSVM/src/Testing/WingmanSuites.cs index b3ba9f250..fa808c7f3 100644 --- a/CSVM/src/Testing/WingmanSuites.cs +++ b/CSVM/src/Testing/WingmanSuites.cs @@ -566,6 +566,7 @@ private static FlightController EngageRig(TestContext ctx, GameZ planesGamez, private static void JitterGate(TestContext ctx) { ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequirePlane(FlownPlaneNode); var wingman = PlaneStats.LoadForAi(ctx.ZrdrPath, FlownPlaneNode, WingmanAiDef); var jet = PlaneStats.LoadForAi(ctx.ZrdrPath, FlownPlaneNode); ctx.Note($"[jitter] '{wingman.AiDefName}' mode={wingman.VehicleMode} fd_speed {wingman.FdSpeed:0.0} m/s, '{jet.AiDefName}' mode={jet.VehicleMode} fd_speed {jet.FdSpeed:0.0} m/s"); diff --git a/CSVM/src/Testing/WorldAndToolSuites.cs b/CSVM/src/Testing/WorldAndToolSuites.cs index 7e4dbe986..755ffc911 100644 --- a/CSVM/src/Testing/WorldAndToolSuites.cs +++ b/CSVM/src/Testing/WorldAndToolSuites.cs @@ -409,8 +409,7 @@ internal static void CockpitInterior(TestContext ctx) $"scaled to the port's interior scale scale={interior.Scale.X:0.###}"); ctx.Check(builder.MeshInstanceCount > plain.MeshInstanceCount, $"the interior adds meshes plain={plain.MeshInstanceCount} with={builder.MeshInstanceCount}"); - // The panel the pilot reads and the two torn-skin panels B12 drives, both hidden. - ctx.Check(FindNamed(interior, "gauges") != null, $"the interior carries its gauges subtree"); + // The two torn-skin panels the pdpanel4/pdpanel6 injure entries drive, both hidden. foreach (var panel in new[] { "pcdp4", "pcdp6" }) { var node = FindNamed(interior, panel); @@ -420,34 +419,12 @@ internal static void CockpitInterior(TestContext ctx) // ⚠ The windshield bullet-hole quads and the two warning lamps ship active:true. An // unparked build renders white splats across the sky on a pristine plane. The parking // follows reset_bulletholes: the bulNx quads dark, the bulletN groups over them drawn. - foreach (var lamp in new[] { "lowalt_on", "stallwarning_on" }) + ParkedStates(ctx, interior); + bool gauged = FindNamed(interior, "gauges") != null; + if (!ctx.SyntheticData) { - var node = FindNamed(interior, lamp); - ctx.Check(node != null, $"the interior carries {lamp}"); - ctx.Check(node is not { Visible: true }, $"{lamp} is parked hidden on a pristine plane"); + CockpitNamedSet(ctx, interior); } - foreach (var group in new[] { "bullet1", "bullet2", "bullet3", "bullet4", "bullet5" }) - { - var node = FindNamed(interior, group); - ctx.Check(node is { Visible: true }, $"the interior carries {group}, drawn"); - if (node == null) - continue; - int quads = 0, lit = 0; - foreach (var child in node.GetChildren()) - { - if (child is not Node3D quad) - continue; - quads++; - if (quad.Visible) - lit++; - } - ctx.Check(quads >= 3 && lit == 0, - $"{group}'s {quads} hole quads are parked hidden on a pristine plane, lit={lit}"); - } - // …and the panel geometry beside them is NOT parked: the states are a named set, not a - // blanket hide, so a wrong predicate that hid the dashboard would fail here. - foreach (var kept in new[] { "gauges", "structure", "nosedamage", "ggindicator0" }) - ctx.Check(FindNamed(interior, kept) is { Visible: true }, $"{kept} still renders"); // The exterior panels stay DamageVisuals' alone: the interior pair must not join them. foreach (var node in builder.DamagePanels) @@ -491,8 +468,13 @@ internal static void CockpitInterior(TestContext ctx) ctx.Check(!interior.Visible && DrawsAll(body, ownPane) && DrawsAll(dontmove, ownPane), $"a held external view restores the aircraft while Cockpit stays selected {DrawnBy(body, ownPane)}"); - DrivenPanel(ctx, interior); - DrivenBelts(ctx, interior, builder, planesGamez, textures); + // The gauge drive needs an authored panel. A tree without one has nothing to drive; + // the install always carries one. + if (gauged || !ctx.SyntheticData) + { + DrivenPanel(ctx, interior); + DrivenBelts(ctx, interior, builder, planesGamez, textures); + } } finally { @@ -502,6 +484,78 @@ internal static void CockpitInterior(TestContext ctx) } } + // The parked set as the builder's own predicate names it in whatever interior was loaded. Each + // named node is dark, the group over each hole quad is drawn, and meshes outside the set render. + // Able to fail: a pass that misses a named node, hides a hole group, or hides everything. + internal static void ParkedStates(TestContext ctx, Node3D interior) + { + var parked = new List(); + var drawnMeshes = new List(); + void Walk(Node3D node) + { + if (PlaneBuilder.IsInteriorDrivenState(AnimRuntime.NameOf(node))) + parked.Add(node); + else if (node is MeshInstance3D && node.Visible + && !AnimRuntime.NameOf(node).StartsWith("pcdp", System.StringComparison.OrdinalIgnoreCase)) + drawnMeshes.Add(node); + foreach (var child in node.GetChildren()) + { + if (child is Node3D n3d) + Walk(n3d); + } + } + Walk(interior); + ctx.Check(parked.Count > 0, $"the interior names {parked.Count} driven-state node(s) for the parking pass"); + foreach (var node in parked) + { + string name = AnimRuntime.NameOf(node); + ctx.Check(!node.Visible, $"{name} is parked hidden on a pristine plane"); + if (!name.EndsWith("_on", System.StringComparison.OrdinalIgnoreCase)) + { + ctx.Check(node.GetParent() is Node3D { Visible: true }, + $"…and the hole group over {name} is drawn ({(node.GetParent() as Node3D)?.Name.ToString() ?? "-"})"); + } + } + ctx.Check(drawnMeshes.Count > 0, + $"the panel geometry outside the set still renders, so the parking is not a blanket hide ({drawnMeshes.Count} meshes)"); + } + + // The shipped interior's own named set, which only the install carries. It holds the gauges + // panel and the two warning lamps. It also holds five bullet-hole groups of three or more quads + // each, and the panel parts that must keep rendering. + internal static void CockpitNamedSet(TestContext ctx, Node3D interior) + { + ctx.Check(FindNamed(interior, "gauges") != null, $"the interior carries its gauges subtree"); + foreach (var lamp in new[] { "lowalt_on", "stallwarning_on" }) + { + var node = FindNamed(interior, lamp); + ctx.Check(node != null, $"the interior carries {lamp}"); + ctx.Check(node is not { Visible: true }, $"{lamp} is parked hidden on a pristine plane"); + } + foreach (var group in new[] { "bullet1", "bullet2", "bullet3", "bullet4", "bullet5" }) + { + var node = FindNamed(interior, group); + ctx.Check(node is { Visible: true }, $"the interior carries {group}, drawn"); + if (node == null) + continue; + int quads = 0, lit = 0; + foreach (var child in node.GetChildren()) + { + if (child is not Node3D quad) + continue; + quads++; + if (quad.Visible) + lit++; + } + ctx.Check(quads >= 3 && lit == 0, + $"{group}'s {quads} hole quads are parked hidden on a pristine plane, lit={lit}"); + } + // …and the panel geometry beside them is NOT parked. The states are a named set, not a + // blanket hide, so a wrong predicate that hid the dashboard would fail here. + foreach (var kept in new[] { "gauges", "structure", "nosedamage", "ggindicator0" }) + ctx.Check(FindNamed(interior, kept) is { Visible: true }, $"{kept} still renders"); + } + // The photograph's fill light, proved in the three things the pixels follow from, since a // suite cannot read pixels back. The faithful aircraft shader swaps its ambient half for the // fill only at an armed eye. The photograph's frame arms that eye on its own pilot's instances @@ -515,9 +569,12 @@ internal static void DangerZonePhotographFill(TestContext ctx) ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + var (chapter, mission) = WeatherMission(ctx, "C1", "IA1"); + string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, chapter, mission); + ctx.RequireZrdrEntry(missionZrdr, "weather.json"); - var weather = WeatherState.Load(SessionPaths.MissionZrdr(ctx.DataRoot, "C1", "IA1")); - ctx.Check(weather != null, $"C1 IA1's weather loads"); + var weather = WeatherState.Load(missionZrdr); + ctx.Check(weather != null, $"{chapter} {mission}'s weather loads"); if (weather != null) { var zone = weather.Zone("zone1"); @@ -1244,8 +1301,13 @@ internal static void TexDropIn(TestContext ctx) string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); using var textures = new TextureArchive(texturesPath); + // The synthetic archive carries none of the shipped samples, so it flattens every texture + // its own manifest lists, all of them without alpha. + IReadOnlyList samples = ctx.SyntheticData ? ManifestTextureNames(texturesPath) : DropInSamples; + ctx.Check(samples.Count > 0, $"the archive has sample textures count={samples.Count}"); + var colors = new Dictionary(); - foreach (string name in DropInSamples) + foreach (string name in samples) { var img = textures.FindImage(name); ctx.Check(img != null, $"sample texture resolves texture={name}"); @@ -1303,6 +1365,17 @@ internal static void TexDropIn(TestContext ctx) ctx.Note($"{colors.Count} sample textures flattened, {seen.Count} distinct colours"); } + // Every texture an unpacked archive's extraction manifest lists, empty when it lists none. + internal static List ManifestTextureNames(string archive) + { + string manifest = Path.Combine(archive, "manifest.json"); + if (!File.Exists(manifest)) + return new List(); + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(manifest)); + return doc.RootElement.GetProperty("texture_infos").EnumerateArray() + .Select(info => info.GetProperty("name").GetString() ?? "").ToList(); + } + // templates.zrd's substitute and scale_range, asserted as an A/B against the build that does not // read the spec at all: the same ClutterBuilder over the same gamez, differing only in whether a // spec was handed to it. Three builds, each with its own able-to-fail control: bare, dressed, and @@ -1592,6 +1665,7 @@ internal static void WingFlarePose(TestContext ctx) ctx.RequireData(ctx.PlanesGamezPath, $"planes gamez"); string texturesPath = SessionPaths.ChapterTextures(ctx.DataRoot, "C1"); ctx.RequireData(texturesPath, $"C1 textures"); + ctx.RequirePlane(plane); var planesGamez = GameZ.Load(ctx.PlanesGamezPath); using var textures = new TextureArchive(texturesPath); @@ -1694,8 +1768,16 @@ int Drawn(Vector3 from, Material material) "the world's light wears the flown zone's authored SUNLIGHT_ORIENTATION, and follows it across a zone change (BL-324)")] internal static void SunOrientation(TestContext ctx) { - string zrdr = SessionPaths.MissionZrdr(ctx.DataRoot, "C2", "MP2"); - ctx.RequireData(zrdr, $"C2/MP2 mission zrdr"); + var (chapter, mission) = WeatherMission(ctx, "C2", "MP2"); + string zrdr = SessionPaths.MissionZrdr(ctx.DataRoot, chapter, mission); + ctx.RequireData(zrdr, $"{chapter}/{mission} mission zrdr"); + var zone1 = AuthoredBearing(ctx, zrdr, "zone1", -65f, 90f); + var zone2 = AuthoredBearing(ctx, zrdr, "zone2", -25f, 90f); + if (ctx.SyntheticData) + { + ctx.Check(!Mathf.IsEqualApprox(zone1.Pitch, zone2.Pitch) || !Mathf.IsEqualApprox(zone1.Yaw, zone2.Yaw), + $"{chapter}/{mission}'s two zones author different bearings, so a zone change can show ({zone1} against {zone2})"); + } var sun = new DirectionalLight3D { Name = "sun-orientation-probe" }; ctx.Host.AddChild(sun); @@ -1706,29 +1788,29 @@ internal static void SunOrientation(TestContext ctx) // No --sky-zone: an explicit one disarms the state machine outright, which // would make the zone change below unobservable. The default request is zone2, and with // no horizon geometry to correct it that is what the flight builds with. - var spec = SessionSpec.Parse(new[] { "--chapter=C2", "--mission=MP2" }); + var spec = SessionSpec.Parse(new[] { $"--chapter={chapter}", $"--mission={mission}" }); var rig = new PlayerRig { Index = 0, Camera = camera, HudParent = ctx.Host }; var rigs = new List { rig }; var weatherRig = new WeatherRig(spec, ctx.Host, sun); weatherRig.Build(zrdr, rigs, System.Array.Empty(), _ => { }); - ctx.Check(NearDegrees(sun.RotationDegrees, -25f, 90f), - $"C2/MP2 builds at ZONE2's bearing (got {sun.RotationDegrees.X:0.#}°/{sun.RotationDegrees.Y:0.#}°)"); + ctx.Check(NearDegrees(sun.RotationDegrees, zone2.Pitch, zone2.Yaw), + $"{chapter}/{mission} builds at ZONE2's bearing (got {sun.RotationDegrees.X:0.#}°/{sun.RotationDegrees.Y:0.#}°)"); - // Below the cloud band (19024–20124 m) the camera is in weather state 1, so the edge - // trigger swaps to ZONE1, and the light must ride along. Ticked twice: the first Tick + // Below the cloud band (19024–20124 m in C2/MP2) the camera is in weather state 1. The + // edge trigger swaps to ZONE1, and the light must ride along. Ticked twice: the first Tick // publishes the rig's new state, and the swap is asserted after it has settled. camera.Position = new Vector3(0f, 0f, 0f); weatherRig.Tick(rigs); ctx.Same(1, rig.CameraWeatherState, $"camera below the band is in weather state 1"); - ctx.Check(NearDegrees(sun.RotationDegrees, -65f, 90f), + ctx.Check(NearDegrees(sun.RotationDegrees, zone1.Pitch, zone1.Yaw), $"a zone change carries the sun to ZONE1's bearing (got {sun.RotationDegrees.X:0.#}°/{sun.RotationDegrees.Y:0.#}°)"); // ...and back. A one-way test would pass on a light that moved once and stuck. camera.Position = new Vector3(0f, 25000f, 0f); weatherRig.Tick(rigs); ctx.Same(2, rig.CameraWeatherState, $"camera above the band is in weather state 2"); - ctx.Check(NearDegrees(sun.RotationDegrees, -25f, 90f), + ctx.Check(NearDegrees(sun.RotationDegrees, zone2.Pitch, zone2.Yaw), $"and back to ZONE2's on the return crossing (got {sun.RotationDegrees.X:0.#}°/{sun.RotationDegrees.Y:0.#}°)"); // The other half, asserted where it would regress: shadow mapping stays off, so this @@ -1748,6 +1830,22 @@ internal static void SunOrientation(TestContext ctx) internal static bool NearDegrees(Vector3 rotationDegrees, float pitch, float yaw) => Mathf.Abs(rotationDegrees.X - pitch) < 0.1f && Mathf.Abs(rotationDegrees.Y - yaw) < 0.1f; + // The mission a weather suite flies: the shipped one on an install, the invented PROBE1 + // scope on the synthetic tree, which carries no shipped mission. + internal static (string Chapter, string Mission) WeatherMission(TestContext ctx, string chapter, string mission) + => ctx.SyntheticData ? (SyntheticMission.Chapter, SyntheticMission.Mission) : (chapter, mission); + + // A zone's sun bearing in degrees: the shipped literal on an install, the record's own on the + // synthetic tree. The CSVM.Tests units pin the reader, so the suite's question stays the wiring. + internal static (float Pitch, float Yaw) AuthoredBearing(TestContext ctx, string zrdr, string zone, + float pitch, float yaw) + { + if (!ctx.SyntheticData) + return (pitch, yaw); + var authored = WeatherState.Load(zrdr)?.Zone(zone).SunOrientation ?? Vector3.Zero; + return (Mathf.RadToDeg(authored.X), Mathf.RadToDeg(authored.Y)); + } + // The world's light takes the flown zone's authored SUNLIGHT as its ENERGY, not only its // bearing. The CSVM.Tests units pin both mappings; neither can see the write missing from the // zone apply, and a plane lit at one level everywhere is what that looks like. @@ -1824,10 +1922,15 @@ internal static void SunEnergy(TestContext ctx) "the cockpit pass's own sun is aimed where the mission's SUNLIGHT_ORIENTATION points, in the pass's world basis, and follows the session sun across a zone change (BL-684)")] internal static void CockpitSunBearing(TestContext ctx) { - string litZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, "C1", "IA1"); - string crossZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, "C2", "MP2"); - ctx.RequireData(litZrdr, $"C1/IA1 mission zrdr"); - ctx.RequireData(crossZrdr, $"C2/MP2 mission zrdr"); + var lit = WeatherMission(ctx, "C1", "IA1"); + var cross = WeatherMission(ctx, "C2", "MP2"); + string litZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, lit.Chapter, lit.Mission); + string crossZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, cross.Chapter, cross.Mission); + ctx.RequireData(litZrdr, $"{lit.Chapter}/{lit.Mission} mission zrdr"); + ctx.RequireData(crossZrdr, $"{cross.Chapter}/{cross.Mission} mission zrdr"); + var litBearing = AuthoredBearing(ctx, litZrdr, "zone1", -25f, 90f); + var below1 = AuthoredBearing(ctx, crossZrdr, "zone1", -65f, 90f); + var inside2 = AuthoredBearing(ctx, crossZrdr, "zone2", -25f, 90f); var sun = new DirectionalLight3D { Name = "cockpit-bearing-sun" }; var camera = new Camera3D { Name = "cockpit-bearing-camera" }; @@ -1840,7 +1943,7 @@ internal static void CockpitSunBearing(TestContext ctx) { var env = new Godot.Environment(); var litSpec = SessionSpec.Parse( - new[] { "--chapter=C1", "--mission=IA1", "--sky-zone=zone1" }); + new[] { $"--chapter={lit.Chapter}", $"--mission={lit.Mission}", "--sky-zone=zone1" }); var litRig = new WeatherRig(litSpec, ctx.Host, sun, env: env); litRig.Build(litZrdr, System.Array.Empty(), System.Array.Empty(), _ => { }); @@ -1859,10 +1962,10 @@ internal static void CockpitSunBearing(TestContext ctx) var beam = -clone.GlobalBasis.Z; // C1's ZONE1 authors -25° pitch / 90° yaw, and the expected beam is the binary's own // euler→direction law rather than the reader's, so a wrong reader cannot agree with it. - var authored = SunBeam(Mathf.DegToRad(-25f), Mathf.DegToRad(90f)); - ctx.Note($"C1/IA1 zone1: session sun {world}, interior sun {beam}"); + var authored = SunBeam(Mathf.DegToRad(litBearing.Pitch), Mathf.DegToRad(litBearing.Yaw)); + ctx.Note($"{lit.Chapter}/{lit.Mission} zone1: session sun {world}, interior sun {beam}"); ctx.Check(world.AngleTo(authored) < 0.001f, - $"the session sun takes C1's authored -25°/90° bearing off={world.AngleTo(authored):0.0000} rad"); + $"the session sun takes {lit.Chapter}'s authored {litBearing.Pitch:0.#}°/{litBearing.Yaw:0.#}° bearing off={world.AngleTo(authored):0.0000} rad"); ctx.Check(beam.AngleTo(world) < 0.001f, $"the interior's own sun is aimed the same way off={beam.AngleTo(world):0.0000} rad"); ctx.Check(beam.AngleTo(Vector3.Forward) > 0.5f, @@ -1878,20 +1981,20 @@ internal static void CockpitSunBearing(TestContext ctx) // bearing, -65°/90° below the band against -25°/90° inside it. var rigs = new List { new PlayerRig { Index = 0, Camera = camera, HudParent = ctx.Host } }; - var crossSpec = SessionSpec.Parse(new[] { "--chapter=C2", "--mission=MP2" }); + var crossSpec = SessionSpec.Parse(new[] { $"--chapter={cross.Chapter}", $"--mission={cross.Mission}" }); var crossRig = new WeatherRig(crossSpec, ctx.Host, sun, env: env); crossRig.Build(crossZrdr, rigs, System.Array.Empty(), _ => { }); crossRig.RegisterExtraLighting(clone, pass.Env); var below = CrossedBeam(crossRig, pass, cam, rigs, Vector3.Zero, clone, sun); var inside = CrossedBeam(crossRig, pass, cam, rigs, new Vector3(0f, 25000f, 0f), clone, sun); - ctx.Same(1, below.State, $"a camera under C2/MP2's 19,024-20,124 m band is in weather state 1"); + ctx.Same(1, below.State, $"a camera under {cross.Chapter}/{cross.Mission}'s cloud band is in weather state 1"); ctx.Same(2, inside.State, $"and one at 25,000 m is in state 2"); - ctx.Note($"C2/MP2 below band {below.Beam}, inside band {inside.Beam}"); - ctx.Check(below.Beam.AngleTo(SunBeam(Mathf.DegToRad(-65f), Mathf.DegToRad(90f))) < 0.001f, - $"below the band the interior wears ZONE1's -65°/90°"); - ctx.Check(inside.Beam.AngleTo(SunBeam(Mathf.DegToRad(-25f), Mathf.DegToRad(90f))) < 0.001f, - $"inside it the interior wears ZONE2's -25°/90°"); + ctx.Note($"{cross.Chapter}/{cross.Mission} below band {below.Beam}, inside band {inside.Beam}"); + ctx.Check(below.Beam.AngleTo(SunBeam(Mathf.DegToRad(below1.Pitch), Mathf.DegToRad(below1.Yaw))) < 0.001f, + $"below the band the interior wears ZONE1's {below1.Pitch:0.#}°/{below1.Yaw:0.#}°"); + ctx.Check(inside.Beam.AngleTo(SunBeam(Mathf.DegToRad(inside2.Pitch), Mathf.DegToRad(inside2.Yaw))) < 0.001f, + $"inside it the interior wears ZONE2's {inside2.Pitch:0.#}°/{inside2.Yaw:0.#}°"); ctx.Check(below.Beam.AngleTo(inside.Beam) > 0.6f, $"the crossing moved the bearing by {Mathf.RadToDeg(below.Beam.AngleTo(inside.Beam)):0.#}°"); ctx.Check(below.Off < 0.001f && inside.Off < 0.001f, @@ -1917,8 +2020,11 @@ internal static void CockpitSunBearing(TestContext ctx) "the cloud-band whiteout draws under each pane's own cockpit pass, so a full-opacity band whites the window out and leaves the canopy, panel and gauges clear, in cockpit view and in chase view alike, while --no-fog clears it")] internal static void WeatherCockpitWhiteout(TestContext ctx) { - string zrdr = SessionPaths.MissionZrdr(ctx.DataRoot, "C1", "IA1"); - ctx.RequireData(zrdr, $"C1/IA1 mission zrdr"); + var (chapter, mission) = WeatherMission(ctx, "C1", "IA1"); + string zrdr = SessionPaths.MissionZrdr(ctx.DataRoot, chapter, mission); + ctx.RequireData(zrdr, $"{chapter}/{mission} mission zrdr"); + // C1/IA1's band is 970-1124 m, so its centre is 1047 m. + float core = ctx.SyntheticData ? WeatherState.Load(zrdr)?.CloudBandCentre ?? 0f : 1047f; // The order the picture is composed in, asserted on the constants themselves: the flare // and the whiteout are the sky, the pass is the cockpit in front of it, and the HUD is the @@ -1943,7 +2049,7 @@ internal static void WeatherCockpitWhiteout(TestContext ctx) CockpitOverlay? pass = null; try { - var spec = SessionSpec.Parse(new[] { "--chapter=C1", "--mission=IA1" }); + var spec = SessionSpec.Parse(new[] { $"--chapter={chapter}", $"--mission={mission}" }); var rigs = new List { new PlayerRig { Index = 0, Camera = camera0, HudParent = pane0 }, @@ -1966,7 +2072,7 @@ internal static void WeatherCockpitWhiteout(TestContext ctx) // C1/IA1's band is 970-1124 m with a 1032-1062 m opaque core, so the centre is a // total whiteout and the flicker's own guard leaves it there untouched. - camera0.Position = new Vector3(0f, 1047f, 0f); + camera0.Position = new Vector3(0f, core, 0f); camera1.Position = Vector3.Zero; weather.Tick(rigs); float inside = rigs[0].Whiteout?.Color.A ?? -1f; @@ -2002,7 +2108,7 @@ internal static void WeatherCockpitWhiteout(TestContext ctx) // --no-fog covers the whiteout as it covers the fog, at the same altitude. var clearSpec = SessionSpec.Parse( - new[] { "--chapter=C1", "--mission=IA1", "--no-fog" }); + new[] { $"--chapter={chapter}", $"--mission={mission}", "--no-fog" }); var clearRigs = new List { new PlayerRig { Index = 0, Camera = camera0, HudParent = pane1 } }; var clearWeather = new WeatherRig(clearSpec, ctx.Host, sun); @@ -2079,7 +2185,7 @@ internal static void LensFlareGates(TestContext ctx) [Suite("nodelab-visibility", "the node lab's tree row follows live Visible, not the hide button's last action")] internal static void NodeLabVisibility(TestContext ctx) { - ctx.WithWorld("C2", collision: false, world => + EffectStageSuiteHelper.WithAnimWorld(ctx, "C2", world => { DestructibleRegistry.Instance? chosen = null; Node3D? healthy = null; @@ -2103,9 +2209,9 @@ internal static void NodeLabVisibility(TestContext ctx) return; } - var selection = new SelectionService(world.Session.Root, ctx.Camera); - var lab = new NodeLab(world.Session.Root, selection, world.Runtime, world.Session.Program, - world.Session.Builder.Scene, collisionBuilt: false); + var selection = new SelectionService(world.Root, ctx.Camera); + var lab = new NodeLab(world.Root, selection, world.Runtime, world.Program, + world.Scene, collisionBuilt: false); ctx.Host.AddChild(selection); ctx.Host.AddChild(lab); try diff --git a/CSVM/src/Testing/WorldFidelitySuites.cs b/CSVM/src/Testing/WorldFidelitySuites.cs index e43e85f4a..cd8fcc93e 100644 --- a/CSVM/src/Testing/WorldFidelitySuites.cs +++ b/CSVM/src/Testing/WorldFidelitySuites.cs @@ -468,6 +468,8 @@ internal static void GeneratorLaunchClimbOut(TestContext ctx) + "hangar")] internal static void HangarDoorWake(TestContext ctx) { + ctx.RequireData(ctx.ZrdrPath, $"zrdr archive"); + ctx.RequireZrdrEntry(ctx.ZrdrPath, CampaignSequence.FileName); CampaignMission? found = null; foreach (var m in CampaignSequence.Load(ctx.ZrdrPath)) { @@ -484,6 +486,7 @@ internal static void HangarDoorWake(TestContext ctx) } string missionZrdr = SessionPaths.MissionZrdr(ctx.DataRoot, DoorChapter, DoorMission); + ctx.RequireData(missionZrdr, $"{DoorChapter}/{DoorMission} zrdr"); var script = ObjectiveScript.Load(missionZrdr); var director = CampaignDirector.Create(script, mission, CampaignProfileDef.NewProfile("Zachary"), null); ctx.WithWorld(DoorChapter, collision: false, DoorMission, world => diff --git a/CSVM/src/Tooling/ProbeRunner.cs b/CSVM/src/Tooling/ProbeRunner.cs index 07fed89dd..fa02934a5 100644 --- a/CSVM/src/Tooling/ProbeRunner.cs +++ b/CSVM/src/Tooling/ProbeRunner.cs @@ -259,6 +259,7 @@ public int RunTestSuites(SessionSpec spec, Node parent, Camera3D camera, out Gam { RepoRoot = _repoRoot, DataRoot = _dataRoot, + SyntheticData = SyntheticData.Marks(_dataRoot), Chapter = spec.Chapter, Mission = spec.Mission, ZrdrPath = _zrdrPath, diff --git a/CSVM/src/Tooling/SyntheticData.cs b/CSVM/src/Tooling/SyntheticData.cs new file mode 100644 index 000000000..23b2a2fa6 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticData.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using CSVM.Extraction; + +namespace CSVM.Tooling; + +/// +/// The synthetic data root behind --synthetic-data: an extracted/ tree of invented +/// records and code-generated files. It is written at run time, so every loader takes its normal +/// path with no install. The hand-authored records are the CSVM.Tests/fixtures/ files, read +/// from the repo checkout: the tests and the engine share one copy, and an export carries none. +/// Each record family is one entry in or, for the Original shell, in +/// SyntheticShell.TreeFamilies. The tree's stamp carries a +/// field, which is how tells a synthetic tree apart. +/// Engine-free, so CSVM.Tests builds the same tree the engine does. +/// +public static class SyntheticData +{ + /// The stamp field naming the tree synthetic, beside the schema every + /// extraction writes. The boot stamp check reads only schema, so the field is inert there. + public const string StampField = "synthetic"; + + /// The folder under the repo's .scratch/ that holds one tree per process. + public const string ScratchFolder = "synthetic-data"; + + /// The record families written from this namespace, in this order. Adding a family is one + /// entry here and one writer that touches only its own folder under extracted/. The + /// Original shell's family reads UI types, so it joins in SyntheticShell.TreeFamilies. + public static readonly IReadOnlyList Families = new SyntheticFamily[] + { + new("chapter-textures", SyntheticTextures.WriteChapter), + new("plane", SyntheticPlane.WritePlane), + new("armament", SyntheticPlane.WriteArmament), + new("sounds", SyntheticSounds.WriteArchive), + new("voice", SyntheticSounds.WriteVoice), + new("mission", SyntheticMission.WriteMission), + new("effects", SyntheticEffects.Write), + }; + + /// Where the hand-authored records sit in a repo checkout. An exported build has no + /// such folder, which is what keeps the switch out of a shipped game. + public static string FixturesUnder(string repoRoot) => Path.Combine(repoRoot, "CSVM.Tests", "fixtures"); + + /// This process's data root under 's scratch folder, after + /// removing the trees of processes that have exited. Per process, so concurrent shards never + /// write into a tree another one is reading. + public static string ScratchRoot(string repoRoot) + { + string parent = Path.Combine(repoRoot, ".scratch", ScratchFolder); + SweepStale(parent); + return Path.Combine(parent, Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + } + + /// Writes and stamps the whole tree under , replacing a + /// synthetic tree an earlier run left there. Throws when a fixture is missing or a write fails. + /// The caller then reads nothing, since falling back to the install is the one wrong answer. + /// is the whole tree's list, SyntheticShell.TreeFamilies. + public static void Build(string fixturesRoot, string dataRoot, IReadOnlyList families) + { + if (!Directory.Exists(fixturesRoot)) + { + throw new DirectoryNotFoundException($"no hand-authored records at {fixturesRoot} (a repo checkout carries them, an export does not)"); + } + + string extracted = Path.Combine(dataRoot, ExtractionRun.ExtractedFolder); + if (Directory.Exists(extracted)) + { + // ⚠ Never delete a tree this class did not stamp; a real extraction at the root stops the build. + if (!Marks(dataRoot)) + { + throw new IOException($"{extracted} holds a tree not stamped synthetic, refusing to replace it"); + } + + Directory.Delete(extracted, recursive: true); + } + + // Stamped first, so a tree a failed build leaves half written is still one a rebuild may replace. + var tree = new SyntheticTree(fixturesRoot, dataRoot); + var names = new JsonArray(families.Select(f => (JsonNode?)JsonValue.Create(f.Name)).ToArray()); + ExtractionStampWriter.Merge(tree.Extracted, StampField, new JsonObject + { + ["script"] = "CSVM", + ["families"] = names, + }); + foreach (var family in families) + { + family.Write(tree); + } + } + + /// Whether the tree under is stamped synthetic. False for a + /// missing, unreadable or real stamp, so only a tree this class wrote reads true. + public static bool Marks(string dataRoot) + { + string path = Path.Combine(dataRoot, ExtractionRun.ExtractedFolder, ExtractionStampWriter.FileName); + if (!File.Exists(path)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty(StampField, out _); + } + catch (Exception e) when (e is IOException or JsonException) + { + return false; + } + } + + // Removes the trees whose process has exited, so the scratch folder holds one per live run rather + // than one per run ever made. A live owner, or one this process cannot query, keeps its tree. + private static void SweepStale(string parent) + { + if (!Directory.Exists(parent)) + { + return; + } + + foreach (string dir in Directory.EnumerateDirectories(parent)) + { + if (!int.TryParse(Path.GetFileName(dir), NumberStyles.None, CultureInfo.InvariantCulture, out int pid) + || pid == Environment.ProcessId || Alive(pid)) + { + continue; + } + + try + { + Directory.Delete(dir, recursive: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // A tree another tool still holds open is left for CleanScratch.ps1. + } + } + } + + private static bool Alive(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (Exception e) when (e is InvalidOperationException or System.ComponentModel.Win32Exception) + { + return true; + } + } +} + +/// One record family of the synthetic tree: a name for the stamp and the log, and the +/// writer that puts its files under . +public sealed record SyntheticFamily(string Name, Action Write); + +/// What a family writer is handed: where the hand-authored records are and where the tree +/// is. It also holds the two moves every family makes, copying a record and writing generated bytes. +public sealed class SyntheticTree +{ + internal SyntheticTree(string fixturesRoot, string dataRoot) + { + FixturesRoot = fixturesRoot; + DataRoot = dataRoot; + Extracted = Path.Combine(dataRoot, ExtractionRun.ExtractedFolder); + } + + /// The CSVM.Tests/fixtures/ folder the records are read from. + public string FixturesRoot { get; } + + /// The data root the run reads, the folder sits in. + public string DataRoot { get; } + + /// The tree's extracted/ folder. + public string Extracted { get; } + + /// A record's path under . It throws when the record is + /// absent, so a renamed fixture fails the tree's build instead of leaving a hole. + public string Fixture(string relative) + { + string path = Path.Combine(FixturesRoot, relative.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(path)) + { + throw new FileNotFoundException($"synthetic record {relative} is missing from {FixturesRoot}", path); + } + + return path; + } + + /// A path under , in the case given, with its folder created. + /// The caller spells the case the loader resolves: C1/texture, as a chapter folder and an + /// unpacked texture.zip are named. + public string Under(string relative) + { + string path = Path.Combine(Extracted, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + return path; + } + + /// Copies the record at to under + /// , and returns where it landed. + public string CopyFixture(string fixture, string relative) + { + string to = Under(relative); + File.Copy(Fixture(fixture), to, overwrite: true); + return to; + } + + /// Writes generated to under + /// . + public void WriteBytes(string relative, byte[] bytes) => File.WriteAllBytes(Under(relative), bytes); + + /// Creates the folder under , for a + /// scope a loader is handed whole even when the tree has nothing to put in it yet. + public string Folder(string relative) + { + string path = Path.Combine(Extracted, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/CSVM/src/Tooling/SyntheticData.cs.uid b/CSVM/src/Tooling/SyntheticData.cs.uid new file mode 100644 index 000000000..6a2a21e2b --- /dev/null +++ b/CSVM/src/Tooling/SyntheticData.cs.uid @@ -0,0 +1 @@ +uid://5de3xhgid0pg diff --git a/CSVM/src/Tooling/SyntheticEffects.cs b/CSVM/src/Tooling/SyntheticEffects.cs new file mode 100644 index 000000000..390fd1916 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticEffects.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using CSVM.Extraction; + +namespace CSVM.Tooling; + +/// +/// The synthetic tree's anim and effect records, the effects family. Under +/// it writes a template gamez, one box per meshed node of +/// templates.json. Beside it sits a compiled anim archive in the extraction's shape, split +/// out of cam_anim.json. Under zrdr/ it copies the reader-form destructibles and the +/// two puffer readers. No session +/// loader reads ; a suite finds it through and +/// . Engine-free. +/// +public static class SyntheticEffects +{ + /// The family's own folder under extracted/, a name no extraction writes. + public const string Folder = "probe_effects"; + + private const string Records = "synthetic/"; + + private const string Templates = Records + Folder + "/templates.json"; + + private const string Anims = Records + Folder + "/cam_anim.json"; + + private static readonly (string Fixture, string Target)[] ReaderRecords = + { + (Records + "zrdr/probe_world.json", "zrdr/probe_world.json"), + (Records + "zrdr/flame_ball.json", "zrdr/flame_ball.json"), + (Records + "zrdr/pufftrails.json", "zrdr/pufftrails.json"), + }; + + /// The template gamez under , an unpacked gamez folder. + public static string GamezUnder(string dataRoot) => + Path.Combine(dataRoot, ExtractionRun.ExtractedFolder, Folder, "gamez"); + + /// The compiled anim archive under , an unpacked + /// cam_anim folder. + public static string AnimUnder(string dataRoot) => + Path.Combine(dataRoot, ExtractionRun.ExtractedFolder, Folder, "cam_anim"); + + /// Writes the reader records, the template gamez and the compiled archive. + public static void Write(SyntheticTree tree) + { + foreach (var (fixture, target) in ReaderRecords) + { + tree.CopyFixture(fixture, target); + } + + WriteGamez(tree); + WriteAnims(tree); + } + + // One legacy-shape node per entry, depth first, children by flat index. Each entry with a + // `box` takes the next mesh index, so models.json lists the boxes in the order met. + private static void WriteGamez(SyntheticTree tree) + { + using var doc = JsonDocument.Parse(File.ReadAllText(tree.Fixture(Templates))); + var nodes = new List(); + var boxes = new List<(float[] Lo, float[] Hi, int Material)>(); + foreach (var root in doc.RootElement.GetProperty("nodes").EnumerateArray()) + { + Flatten(root, nodes, boxes); + } + + var nodeArray = new JsonArray(); + foreach (var node in nodes) + { + nodeArray.Add(new JsonObject { ["Object3d"] = node }); + } + + string folder = Folder + "/gamez/"; + tree.WriteBytes(folder + "nodes.json", JsonSerializer.SerializeToUtf8Bytes(nodeArray)); + tree.WriteBytes(folder + "models.json", SyntheticPlane.Models(boxes)); + tree.WriteBytes(folder + "materials.json", + JsonSerializer.SerializeToUtf8Bytes(doc.RootElement.GetProperty("materials"))); + } + + private static int Flatten(JsonElement entry, List nodes, + List<(float[] Lo, float[] Hi, int Material)> boxes) + { + int index = nodes.Count; + var node = new JsonObject { ["name"] = entry.GetProperty("name").GetString() }; + nodes.Add(node); + int mesh = -1; + if (entry.TryGetProperty("box", out var box)) + { + mesh = boxes.Count; + boxes.Add((Vec3(box[0]), Vec3(box[1]), entry.GetProperty("material").GetInt32())); + } + + var children = new JsonArray(); + if (entry.TryGetProperty("children", out var kids)) + { + foreach (var kid in kids.EnumerateArray()) + { + children.Add(Flatten(kid, nodes, boxes)); + } + } + + var at = entry.TryGetProperty("at", out var a) ? Vec3(a) : new[] { 0f, 0f, 0f }; + node["mesh_index"] = mesh; + node["children"] = children; + node["transformation"] = new JsonObject + { + ["translation"] = new JsonObject { ["x"] = at[0], ["y"] = at[1], ["z"] = at[2] }, + ["rotation"] = new JsonObject { ["x"] = 0f, ["y"] = 0f, ["z"] = 0f }, + }; + return index; + } + + private static float[] Vec3(JsonElement e) => + e.GetArrayLength() == 3 + ? new[] { e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle() } + : throw new InvalidDataException( + $"a template vector needs three numbers, has {e.GetArrayLength().ToString(CultureInfo.InvariantCulture)}"); + + // The extraction's archive shape: metadata.json names every definition file, in order. + private static void WriteAnims(SyntheticTree tree) + { + using var doc = JsonDocument.Parse(File.ReadAllText(tree.Fixture(Anims))); + var names = new JsonArray(); + string folder = Folder + "/cam_anim/"; + foreach (var entry in doc.RootElement.GetProperty("defs").EnumerateArray()) + { + string file = entry.GetProperty("file").GetString() + ?? throw new InvalidDataException("an anim definition entry names no file"); + names.Add(file); + tree.WriteBytes(folder + file + ".json", JsonSerializer.SerializeToUtf8Bytes(entry.GetProperty("def"))); + } + + var metadata = new JsonObject { ["anim_def_names"] = names, ["script_names"] = new JsonArray() }; + tree.WriteBytes(folder + "metadata.json", JsonSerializer.SerializeToUtf8Bytes(metadata)); + } +} diff --git a/CSVM/src/Tooling/SyntheticEffects.cs.uid b/CSVM/src/Tooling/SyntheticEffects.cs.uid new file mode 100644 index 000000000..20fb3ce7f --- /dev/null +++ b/CSVM/src/Tooling/SyntheticEffects.cs.uid @@ -0,0 +1 @@ +uid://bpee30e2mjw diff --git a/CSVM/src/Tooling/SyntheticImages.cs b/CSVM/src/Tooling/SyntheticImages.cs new file mode 100644 index 000000000..3344188e5 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticImages.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; + +namespace CSVM.Tooling; + +/// +/// The synthetic tree's two encoders besides Extraction/PngWriter.cs: a baseline greyscale +/// JPEG and an uncompressed TGA. They exist for the menu art a layout or a script names with a +/// .jpg or .tga extension. A loader picks its decoder by extension, so a PNG under a +/// JPEG name is a decode error rather than a picture. Each writes a checker drawn from the name, +/// so a capture shows which file drew where. Engine-free, like the tree. +/// +public static class SyntheticImages +{ + // The checker square's side, one JPEG block, so every block is flat and carries a DC term only. + private const int Square = 8; + + // The standard luminance DC table's code lengths and symbols, categories 0 to 11. + private static readonly byte[] DcCounts = { 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 }; + private static readonly byte[] DcSymbols = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; + + // One two-bit code, symbol 0x00, end of block. + private static readonly byte[] AcCounts = { 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + + private static readonly (int Code, int Length)[] DcCodes = Canonical(DcCounts, DcSymbols.Length); + + /// A baseline JPEG of the name's grey checker, one component over a quantisation table + /// of ones. Every 8x8 block is flat, so each one codes as a DC difference and an end of block. + /// The DC table is the JPEG standard's luminance table; the AC table holds end-of-block alone. + public static byte[] Jpeg(string name, int width, int height) + { + if (width <= 0 || height <= 0 || width > ushort.MaxValue || height > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(width), $"{name}: {width}x{height} is not a JPEG size"); + } + + var (lit, unlit) = Shades(name); + var bytes = new List { 0xFF, 0xD8 }; + Segment(bytes, 0xE0, new byte[] { (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0 }); + var quant = new byte[65]; + Array.Fill(quant, (byte)1); + quant[0] = 0x00; + Segment(bytes, 0xDB, quant); + Segment(bytes, 0xC0, new byte[] { 8, (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, 1, 1, 0x11, 0 }); + Segment(bytes, 0xC4, Concat(new byte[] { 0x00 }, DcCounts, DcSymbols)); + Segment(bytes, 0xC4, Concat(new byte[] { 0x10 }, AcCounts, new byte[] { 0x00 })); + Segment(bytes, 0xDA, new byte[] { 1, 1, 0x00, 0, 63, 0 }); + + var bits = new BitSink(bytes); + int previous = 0; + for (int by = 0; by < (height + Square - 1) / Square; by++) + { + for (int bx = 0; bx < (width + Square - 1) / Square; bx++) + { + int shade = (bx + by) % 2 == 0 ? lit : unlit; + int dc = 8 * (shade - 128); + int diff = dc - previous; + previous = dc; + int category = Category(diff); + bits.Write(DcCodes[category].Code, DcCodes[category].Length); + if (category > 0) + { + bits.Write(diff < 0 ? diff + (1 << category) - 1 : diff, category); + } + + bits.Write(0b00, 2); + } + } + + bits.Flush(); + bytes.Add(0xFF); + bytes.Add(0xD9); + return bytes.ToArray(); + } + + /// An uncompressed 24-bit TGA of the name's checker, stored top row first, the type + /// and origin Mech3/TgaImage.cs and the engine's loader both read. + public static byte[] Tga(string name, int width, int height) + { + if (width <= 0 || height <= 0 || width > ushort.MaxValue || height > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(width), $"{name}: {width}x{height} is not a TGA size"); + } + + var tga = new byte[18 + (width * height * 3)]; + tga[2] = 2; + tga[12] = (byte)width; + tga[13] = (byte)(width >> 8); + tga[14] = (byte)height; + tga[15] = (byte)(height >> 8); + tga[16] = 24; + tga[17] = 0x20; + byte[] rgb = SyntheticTextures.Checker(name, width, height); + for (int p = 0; p < width * height; p++) + { + tga[18 + (p * 3)] = rgb[(p * 3) + 2]; + tga[18 + (p * 3) + 1] = rgb[(p * 3) + 1]; + tga[18 + (p * 3) + 2] = rgb[p * 3]; + } + + return tga; + } + + // The two greys of a name's checker, apart enough to read and drawn from the name's hash. + private static (int Lit, int Unlit) Shades(string name) + { + uint hash = 2166136261; + foreach (char c in name) + { + hash = (hash ^ c) * 16777619; + } + + int lit = 96 + (int)(hash & 0x7F); + return (lit, lit - 64); + } + + private static int Category(int value) + { + int magnitude = Math.Abs(value); + int category = 0; + while (magnitude > 0) + { + category++; + magnitude >>= 1; + } + + return category; + } + + // The canonical Huffman codes for a count table, in symbol order. + private static (int Code, int Length)[] Canonical(byte[] counts, int symbols) + { + var codes = new (int, int)[symbols]; + int code = 0; + int at = 0; + for (int length = 1; length <= counts.Length; length++) + { + for (int i = 0; i < counts[length - 1]; i++) + { + codes[at++] = (code++, length); + } + + code <<= 1; + } + + return codes; + } + + private static void Segment(List bytes, byte marker, byte[] body) + { + bytes.Add(0xFF); + bytes.Add(marker); + bytes.Add((byte)((body.Length + 2) >> 8)); + bytes.Add((byte)(body.Length + 2)); + bytes.AddRange(body); + } + + private static byte[] Concat(params byte[][] parts) + { + var all = new List(); + foreach (var part in parts) + { + all.AddRange(part); + } + + return all.ToArray(); + } + + // The entropy-coded segment's bit writer: most significant bit first, a 0x00 stuffed after + // every 0xFF, and the last byte padded with ones. + private sealed class BitSink + { + private readonly List _bytes; + private int _buffer; + private int _count; + + public BitSink(List bytes) => _bytes = bytes; + + public void Write(int value, int length) + { + for (int i = length - 1; i >= 0; i--) + { + _buffer = (_buffer << 1) | ((value >> i) & 1); + if (++_count == 8) + { + Emit(); + } + } + } + + public void Flush() + { + while (_count != 0) + { + Write(1, 1); + } + } + + private void Emit() + { + _bytes.Add((byte)_buffer); + if (_buffer == 0xFF) + { + _bytes.Add(0x00); + } + + _buffer = 0; + _count = 0; + } + } +} diff --git a/CSVM/src/Tooling/SyntheticImages.cs.uid b/CSVM/src/Tooling/SyntheticImages.cs.uid new file mode 100644 index 000000000..7f7be067b --- /dev/null +++ b/CSVM/src/Tooling/SyntheticImages.cs.uid @@ -0,0 +1 @@ +uid://dmpke68re6eot diff --git a/CSVM/src/Tooling/SyntheticMission.cs b/CSVM/src/Tooling/SyntheticMission.cs new file mode 100644 index 000000000..b99e7bb21 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticMission.cs @@ -0,0 +1,29 @@ +namespace CSVM.Tooling; + +/// +/// The synthetic tree's one invented mission scope, C1/PROBE1/zrdr/. Its hand-authored +/// weather.json has two zones with different sun bearings and fog, under a cloud band whose +/// opaque core sits inside the flight envelope. Its net.json is one 16-entry free-for-all +/// block. The weather, sun and net-table suites read it in place of a shipped mission. No shipped +/// mission name enters the tree, so a suite gated on one still skips rather than failing on a +/// half-filled folder. Shapes: docs/formats/weather.md, docs/formats/net-spawns.md. +/// +public static class SyntheticMission +{ + /// The chapter folder the invented mission sits in, the tree's only chapter. + public const string Chapter = "C1"; + + /// The invented mission's folder name, matching no shipped mission. + public const string Mission = "PROBE1"; + + private static readonly string[] Records = { "weather.json", "net.json" }; + + /// Writes the mission's zrdr scope under . + public static void WriteMission(SyntheticTree tree) + { + foreach (string record in Records) + { + tree.CopyFixture($"synthetic/{Chapter}/{Mission}/zrdr/{record}", $"{Chapter}/{Mission}/zrdr/{record}"); + } + } +} diff --git a/CSVM/src/Tooling/SyntheticMission.cs.uid b/CSVM/src/Tooling/SyntheticMission.cs.uid new file mode 100644 index 000000000..dbccda723 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticMission.cs.uid @@ -0,0 +1 @@ +uid://db457wwm5ceam diff --git a/CSVM/src/Tooling/SyntheticPlane.cs b/CSVM/src/Tooling/SyntheticPlane.cs new file mode 100644 index 000000000..8b66f6f29 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticPlane.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace CSVM.Tooling; + +/// +/// The synthetic tree's stand-in aircraft, , , +/// , and six more under shipped airframe names the +/// suites fly, in two families. The plane family is their models under planes/, box-built +/// airframes each carrying the marker rig. It also holds the vehicle.json, +/// engines.json and player.json records and the maneuver library. The armament +/// family is the weapons in weapons.json, the turret table in ai.json, the shake +/// sources and the message table. Every record is hand-authored from docs/formats/; only +/// models.json is generated, from the box list beside the nodes. +/// +public static class SyntheticPlane +{ + /// The stand-in's model root, and the nodename its player def names. + public const string Plane = "probe_plane"; + + /// The second stand-in's model root. ⚠ The name is the code's, not invented: the lobby + /// door seats this starter airframe from PlanePickerRoster.StockAirframes. A suite that + /// needs two distinct airframes finds it there. Everything under the name is invented. + public const string Fighter = "player_pfighter"; + + /// The custom-plane suites' host airframe (id 6), invented under the code's name like + /// . A custom build composes the code's weapon ids (wep_30..wep_70, + /// wep_06), so weapons.json carries invented records under those too. + public const string Firebrand = "player_fbrand"; + + /// The custom-plane suites' guest airframe (id 1), invented under the code's name. + public const string Avenger = "player_avenger"; + + // A record written for the tree keeps its path under extracted/ beneath this fixtures folder. + private const string Records = "synthetic/"; + + // models.json is generated from this list: one box mesh per entry, in mesh-index order. + private const string BoxList = Records + "planes/boxes.json"; + + // Each record as (fixture, path under extracted/). The maneuver library is the unit tests' + // own, which already holds every step shape the AI pilot's reader takes. + private static readonly (string Fixture, string Target)[] PlaneRecords = + { + (Records + "planes/nodes.json", "planes/nodes.json"), + (Records + "planes/materials.json", "planes/materials.json"), + (Records + "zrdr/vehicle.json", "zrdr/vehicle.json"), + (Records + "zrdr/engines.json", "zrdr/engines.json"), + (Records + "zrdr/player.json", "zrdr/player.json"), + ("zrdr/maneuvers.json", "zrdr/maneuvers.json"), + }; + + private static readonly (string Fixture, string Target)[] ArmamentRecords = + { + (Records + "zrdr/weapons.json", "zrdr/weapons.json"), + (Records + "zrdr/shakes.json", "zrdr/shakes.json"), + (Records + "zrdr/ai.json", "zrdr/ai.json"), + (Records + "messages.json", "messages.json"), + }; + + // A box's six faces as indices into its eight corners, with each face's outward normal. Each + // winds counter-clockwise seen from outside, so its Newell normal points out (docs/formats/gamez.md). + private static readonly (int[] Corners, float X, float Y, float Z)[] Faces = + { + (new[] { 0, 3, 2, 1 }, 0f, 0f, -1f), + (new[] { 4, 5, 6, 7 }, 0f, 0f, 1f), + (new[] { 0, 4, 7, 3 }, -1f, 0f, 0f), + (new[] { 1, 2, 6, 5 }, 1f, 0f, 0f), + (new[] { 3, 7, 6, 2 }, 0f, 1f, 0f), + (new[] { 0, 1, 5, 4 }, 0f, -1f, 0f), + }; + + /// The stand-ins' stock fits, which a run reads in place as + /// StockLoadouts.Supplement. A loadout is engine config, not extracted data, so it stays + /// out of the tree. + public static string LoadoutsUnder(string fixturesRoot) => + Path.Combine(fixturesRoot, "synthetic", "stock_loadouts.json"); + + /// Writes the models under planes/ and the plane records under zrdr/. + /// It also writes the empty C1 chapter scope a flight is handed for its speed cue. + public static void WritePlane(SyntheticTree tree) + { + foreach (var (fixture, target) in PlaneRecords) + { + tree.CopyFixture(fixture, target); + } + + tree.WriteBytes("planes/models.json", Models(tree.Fixture(BoxList))); + tree.Folder(SyntheticTextures.Chapter + "/zrdr"); + } + + /// Writes the weapon catalogue, the shake sources and the message table. + public static void WriteArmament(SyntheticTree tree) + { + foreach (var (fixture, target) in ArmamentRecords) + { + tree.CopyFixture(fixture, target); + } + } + + /// The legacy-shape models.json for a box list. Each entry's min and + /// max corners become one closed box of six quads. Every face takes the entry's + /// material and a whole-texture UV square. + public static byte[] Models(string boxListPath) + { + using var doc = JsonDocument.Parse(File.ReadAllText(boxListPath)); + var boxes = new List<(float[] Lo, float[] Hi, int Material)>(); + foreach (var box in doc.RootElement.EnumerateArray()) + { + boxes.Add((Corner(box, "min"), Corner(box, "max"), box.GetProperty("material").GetInt32())); + } + + return Models(boxes); + } + + /// The legacy-shape models.json for boxes given as corners, one closed box of + /// six quads each, in mesh-index order. + internal static byte[] Models(IEnumerable<(float[] Lo, float[] Hi, int Material)> boxes) + { + using var stream = new MemoryStream(); + using (var json = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + json.WriteStartArray(); + foreach (var (lo, hi, material) in boxes) + { + WriteBox(json, lo, hi, material); + } + + json.WriteEndArray(); + } + + return stream.ToArray(); + } + + private static float[] Corner(JsonElement box, string key) + { + var values = new List(); + foreach (var v in box.GetProperty(key).EnumerateArray()) + { + values.Add(v.GetSingle()); + } + + return values.Count == 3 + ? values.ToArray() + : throw new InvalidDataException($"box '{key}' needs three coordinates, has {values.Count.ToString(CultureInfo.InvariantCulture)}"); + } + + private static void WriteBox(Utf8JsonWriter json, float[] lo, float[] hi, int material) + { + json.WriteStartObject(); + json.WriteStartArray("vertices"); + for (int i = 0; i < 8; i++) + { + // Corners 0-3 run round the near (min z) face, 4-7 the far one in the same order. + WriteVec(json, (i & 3) is 1 or 2 ? hi[0] : lo[0], (i & 3) >= 2 ? hi[1] : lo[1], i >= 4 ? hi[2] : lo[2]); + } + + json.WriteEndArray(); + json.WriteStartArray("normals"); + foreach (var face in Faces) + { + WriteVec(json, face.X, face.Y, face.Z); + } + + json.WriteEndArray(); + json.WriteStartArray("polygons"); + for (int f = 0; f < Faces.Length; f++) + { + json.WriteStartObject(); + WriteInts(json, "vertex_indices", Faces[f].Corners); + WriteInts(json, "normal_indices", new[] { f, f, f, f }); + json.WriteStartObject("flags"); + json.WriteBoolean("unk2", false); + json.WriteBoolean("triangle_strip", false); + json.WriteEndObject(); + json.WriteNumber("unk04", 0); + json.WriteStartArray("materials"); + json.WriteStartObject(); + json.WriteNumber("material_index", material); + json.WriteStartArray("uv_coords"); + foreach (var (u, v) in new[] { (0f, 1f), (1f, 1f), (1f, 0f), (0f, 0f) }) + { + json.WriteStartObject(); + json.WriteNumber("u", u); + json.WriteNumber("v", v); + json.WriteEndObject(); + } + + json.WriteEndArray(); + json.WriteEndObject(); + json.WriteEndArray(); + json.WriteEndObject(); + } + + json.WriteEndArray(); + json.WriteStartArray("lights"); + json.WriteEndArray(); + json.WriteString("model_type", "Default"); + json.WriteEndObject(); + } + + private static void WriteVec(Utf8JsonWriter json, float x, float y, float z) + { + json.WriteStartObject(); + json.WriteNumber("x", x); + json.WriteNumber("y", y); + json.WriteNumber("z", z); + json.WriteEndObject(); + } + + private static void WriteInts(Utf8JsonWriter json, string name, int[] values) + { + json.WriteStartArray(name); + foreach (int v in values) + { + json.WriteNumberValue(v); + } + + json.WriteEndArray(); + } +} diff --git a/CSVM/src/Tooling/SyntheticPlane.cs.uid b/CSVM/src/Tooling/SyntheticPlane.cs.uid new file mode 100644 index 000000000..4e9462045 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticPlane.cs.uid @@ -0,0 +1 @@ +uid://dffeq8jnsox5 diff --git a/CSVM/src/Tooling/SyntheticSounds.cs b/CSVM/src/Tooling/SyntheticSounds.cs new file mode 100644 index 000000000..0fa9814f0 --- /dev/null +++ b/CSVM/src/Tooling/SyntheticSounds.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace CSVM.Tooling; + +/// +/// The synthetic tree's sound archive. Its definitions are the hand-authored +/// fixtures/synthetic/zrdr/sounds.json, copied as it stands. Its WAVs sit in +/// soundsh/, the unpacked form of soundsh.zip. Each is generated MS ADPCM, one per +/// entry of fixtures/synthetic/soundsh/manifest.json, which gives a length and a tone or +/// noise. The manifest must name exactly the WAVs the SETS block names, so the two cannot +/// drift. Shapes: docs/formats/sounds.md. +/// +public static class SyntheticSounds +{ + /// The sample rate every WAV is written at, the 22050 Hz the format page says the + /// shipped archive holds. + public const int Rate = 22050; + + // Where the two records sit under the fixtures root, and where they land under extracted/. + private const string SoundsFixture = "synthetic/zrdr/sounds.json"; + private const string ManifestFixture = "synthetic/" + ArchiveFolder + "/manifest.json"; + private const string ArchiveFolder = "soundsh"; + private const string VoiceFixture = "synthetic/zrdr/voice.json"; + + // The peak level of a generated signal, as a share of full scale. It reads plainly in a capture + // and stays far enough under the clip that the ADPCM step never saturates. + private const double Level = 0.3; + + /// Writes zrdr/sounds.json and the WAV archive under . + /// Throws when a WAV the definitions name has no manifest entry, or the other way round. + public static void WriteArchive(SyntheticTree tree) + { + string sounds = tree.CopyFixture(SoundsFixture, "zrdr/sounds.json"); + var entries = Entries(tree.Fixture(ManifestFixture)); + // Through the loader a session uses, so the names checked are the ones a run resolves. + var named = new HashSet( + Mech3.SoundDefs.Load(Path.GetDirectoryName(sounds)!).Values.Select(d => d.WavName), + StringComparer.OrdinalIgnoreCase); + var listed = new HashSet(entries.Select(e => e.Name), StringComparer.OrdinalIgnoreCase); + var unlisted = named.Where(n => !listed.Contains(n)).ToList(); + var unused = listed.Where(n => !named.Contains(n)).ToList(); + if (unlisted.Count > 0 || unused.Count > 0) + { + throw new InvalidDataException( + $"{ManifestFixture} and {SoundsFixture} disagree: no manifest entry for [{string.Join(", ", unlisted)}], no definition for [{string.Join(", ", unused)}]"); + } + + foreach (var (name, seconds, hz) in entries) + { + tree.WriteBytes($"{ArchiveFolder}/{name}", WavWriter.MsAdpcm(Signal(name, seconds, hz), Rate)); + } + } + + /// Writes zrdr/voice.json, the accent table whose pools name the pilot VO ids + /// the snd_id<N>_* clip sets of sounds.json carry. Shape: + /// docs/formats/combat-voice.md. + public static void WriteVoice(SyntheticTree tree) => tree.CopyFixture(VoiceFixture, "zrdr/voice.json"); + + /// The samples for one manifest entry: a tone at with its octave + /// at a quarter weight, or seeded white noise when is 0. The noise is + /// seeded by the name, so a rebuild writes the same bytes. + public static short[] Signal(string name, double seconds, double hz) + { + int count = Math.Max((int)Math.Round(seconds * Rate), 2); + var samples = new short[count]; + uint state = 2166136261; + foreach (char c in name) + { + state = (state ^ c) * 16777619; + } + + for (int i = 0; i < count; i++) + { + double value; + if (hz > 0) + { + double phase = 2 * Math.PI * hz * i / Rate; + value = (0.75 * Math.Sin(phase)) + (0.25 * Math.Sin(2 * phase)); + } + else + { + state = (state * 1664525) + 1013904223; + value = ((state >> 8) / (double)(1 << 24) * 2) - 1; + } + + samples[i] = (short)Math.Round(value * Level * short.MaxValue); + } + + return samples; + } + + // Every manifest entry's WAV name, length and tone. A missing field is an error in the record, + // since a guessed length would be read as the authored one. + private static List<(string Name, double Seconds, double Hz)> Entries(string manifestPath) + { + using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath)); + var entries = new List<(string, double, double)>(); + foreach (var wav in doc.RootElement.GetProperty("wavs").EnumerateArray()) + { + string name = wav.GetProperty("name").GetString() + ?? throw new InvalidDataException($"{manifestPath}: a wavs entry has a null name"); + entries.Add((name, wav.GetProperty("seconds").GetDouble(), wav.GetProperty("hz").GetDouble())); + } + + return entries; + } +} diff --git a/CSVM/src/Tooling/SyntheticSounds.cs.uid b/CSVM/src/Tooling/SyntheticSounds.cs.uid new file mode 100644 index 000000000..9cccfde1c --- /dev/null +++ b/CSVM/src/Tooling/SyntheticSounds.cs.uid @@ -0,0 +1 @@ +uid://d3fxigecqq3sd diff --git a/CSVM/src/Tooling/SyntheticTextures.cs b/CSVM/src/Tooling/SyntheticTextures.cs new file mode 100644 index 000000000..785fde21e --- /dev/null +++ b/CSVM/src/Tooling/SyntheticTextures.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using CSVM.Extraction; + +namespace CSVM.Tooling; + +/// +/// The synthetic tree's chapter texture archive. The hand-authored extraction manifest in +/// fixtures/synthetic/C1/texture/ is copied as it stands, beside one generated PNG per +/// texture_infos entry at that entry's size. The manifest is the record and the pixels +/// are code, so a texture is added by adding a manifest entry. Its shape is the reader's in +/// TextureArchive and the header fields in docs/org/textures.md. +/// +public static class SyntheticTextures +{ + /// The chapter the archive is written for, the one every plane-only path reads. + public const string Chapter = "C1"; + + // Where the manifest sits under the fixtures root, and where the archive lands under extracted/. + // The archive is the unpacked sibling of C1/texture.zip, which SessionPaths.ChapterTextures takes. + private const string ManifestFixture = "synthetic/" + ArchiveFolder + "/manifest.json"; + private const string ArchiveFolder = Chapter + "/texture"; + + // The checker square's side in texels; every manifest size is a multiple of it. + private const int Square = 8; + + /// Writes the archive under . + public static void WriteChapter(SyntheticTree tree) + { + string manifest = tree.CopyFixture(ManifestFixture, ArchiveFolder + "/manifest.json"); + foreach (var (name, width, height) in Entries(manifest)) + { + tree.WriteBytes($"{ArchiveFolder}/{name}.png", PngWriter.EncodeRgb(width, height, Checker(name, width, height))); + } + } + + /// A checker of the name's own colour against mid grey, RGB with no padding. Distinct + /// per name, so a capture shows which texture drew where. + public static byte[] Checker(string name, int width, int height) + { + uint hash = 2166136261; + foreach (char c in name) + { + hash = (hash ^ c) * 16777619; + } + + byte r = (byte)(64 + (hash & 0x7F)), g = (byte)(64 + ((hash >> 8) & 0x7F)), b = (byte)(64 + ((hash >> 16) & 0x7F)); + var rgb = new byte[width * height * 3]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int i = ((y * width) + x) * 3; + bool lit = ((x / Square) + (y / Square)) % 2 == 0; + rgb[i] = lit ? r : (byte)128; + rgb[i + 1] = lit ? g : (byte)128; + rgb[i + 2] = lit ? b : (byte)128; + } + } + + return rgb; + } + + // Every texture_infos entry's name and size. A missing field is an error in the record, not a + // default, since the loader would read a guessed size as the authored one. + private static List<(string Name, int Width, int Height)> Entries(string manifestPath) + { + using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath)); + var entries = new List<(string, int, int)>(); + foreach (var info in doc.RootElement.GetProperty("texture_infos").EnumerateArray()) + { + string name = info.GetProperty("name").GetString() + ?? throw new InvalidDataException($"{manifestPath}: a texture_infos entry has a null name"); + entries.Add((name, info.GetProperty("width").GetInt32(), info.GetProperty("height").GetInt32())); + } + + return entries; + } +} diff --git a/CSVM/src/Tooling/SyntheticTextures.cs.uid b/CSVM/src/Tooling/SyntheticTextures.cs.uid new file mode 100644 index 000000000..ce50cd67d --- /dev/null +++ b/CSVM/src/Tooling/SyntheticTextures.cs.uid @@ -0,0 +1 @@ +uid://c7cd4pgj6yajm diff --git a/CSVM/src/Tooling/WavWriter.cs b/CSVM/src/Tooling/WavWriter.cs new file mode 100644 index 000000000..dfe5bb7b6 --- /dev/null +++ b/CSVM/src/Tooling/WavWriter.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CSVM.Tooling; + +/// +/// Encodes mono 16-bit samples as a RIFF/WAVE file, plain PCM or MS ADPCM, managed and +/// dependency-free like . The ADPCM layout is the one +/// decodes, and the one docs/formats/sounds.md says the game ships. +/// That is fmt tag 2, 4 bits a sample and the seven standard coefficient pairs. A fact chunk +/// carries the true sample count. Engine-free, so the unit tests round-trip it through the reader. +/// +public static class WavWriter +{ + /// The bytes of one ADPCM block. 256 holds 500 samples a block, mono. + public const int AdpcmBlockAlign = 256; + + // The standard coefficient pairs, in the order every MS ADPCM fmt chunk lists them. + private static readonly (short C1, short C2)[] Coefficients = + { + (256, 0), (512, -256), (0, 0), (192, 64), (240, 0), (460, -208), (392, -232), + }; + + // The step adaptation the decoder applies per nibble, indexed by the unsigned nibble. + private static readonly int[] AdaptTable = + { + 230, 230, 230, 230, 307, 409, 512, 614, + 768, 614, 512, 409, 307, 230, 230, 230, + }; + + /// The samples a mono ADPCM block of bytes carries: the + /// two header samples and two per remaining byte. + public static int AdpcmSamplesPerBlock => ((AdpcmBlockAlign - 7) * 2) + 2; + + /// A whole PCM WAV (fmt tag 1, 16 bits, mono) at Hz. + public static byte[] Pcm16(ReadOnlySpan samples, int rate) + { + var fmt = new List(); + AddCanonicalFmt(fmt, tag: 1, rate, byteRate: rate * 2, blockAlign: 2, bits: 16); + var data = new byte[samples.Length * 2]; + for (int i = 0; i < samples.Length; i++) + { + BitConverter.TryWriteBytes(data.AsSpan(i * 2), samples[i]); + } + + return Riff(("fmt ", fmt.ToArray()), ("data", data)); + } + + /// A whole MS ADPCM WAV (fmt tag 2, 4 bits, mono) at Hz. The + /// last block is padded to full size and the fact chunk trims the decode back to + /// ' own length, as the reader expects. + public static byte[] MsAdpcm(ReadOnlySpan samples, int rate) + { + if (samples.Length < 2) + { + throw new ArgumentException("an ADPCM block opens on two header samples", nameof(samples)); + } + + int perBlock = AdpcmSamplesPerBlock; + int blocks = (samples.Length + perBlock - 1) / perBlock; + var data = new byte[blocks * AdpcmBlockAlign]; + for (int b = 0; b < blocks; b++) + { + int start = b * perBlock; + var block = new short[perBlock]; + for (int i = 0; i < perBlock; i++) + { + // The padded tail repeats the last sample, so it costs the predictor nothing. + block[i] = samples[Math.Min(start + i, samples.Length - 1)]; + } + + EncodeBlock(block, data.AsSpan(b * AdpcmBlockAlign, AdpcmBlockAlign)); + } + + var fmt = new List(); + AddCanonicalFmt(fmt, tag: 2, rate, byteRate: rate * AdpcmBlockAlign / perBlock, blockAlign: AdpcmBlockAlign, bits: 4); + AddShort(fmt, (short)(4 + (Coefficients.Length * 4))); + AddShort(fmt, (short)perBlock); + AddShort(fmt, (short)Coefficients.Length); + foreach (var (c1, c2) in Coefficients) + { + AddShort(fmt, c1); + AddShort(fmt, c2); + } + + return Riff(("fmt ", fmt.ToArray()), ("fact", BitConverter.GetBytes(samples.Length)), ("data", data)); + } + + // Encodes one block under each coefficient pair and keeps the one that tracks the input best. + private static void EncodeBlock(short[] block, Span into) + { + long bestError = long.MaxValue; + byte[]? best = null; + for (int pair = 0; pair < Coefficients.Length; pair++) + { + var bytes = new byte[into.Length]; + long error = EncodeWith(block, pair, bytes); + if (error < bestError) + { + bestError = error; + best = bytes; + } + } + + best!.CopyTo(into); + } + + // One block under one coefficient pair, tracked exactly as the decoder replays it. The header + // is pair, step, s1 and s2; then comes one nibble per sample, high nibble first. + private static long EncodeWith(short[] block, int pair, byte[] into) + { + var (c1, c2) = Coefficients[pair]; + int s2 = block[0], s1 = block[1]; + int firstPrediction = ((s1 * c1) + (s2 * c2)) >> 8; + int delta = Math.Max(Math.Abs(block[2] - firstPrediction) / 2, 16); + into[0] = (byte)pair; + BitConverter.TryWriteBytes(into.AsSpan(1), (short)Math.Min(delta, short.MaxValue)); + BitConverter.TryWriteBytes(into.AsSpan(3), (short)s1); + BitConverter.TryWriteBytes(into.AsSpan(5), (short)s2); + delta = Math.Min(delta, short.MaxValue); + + long error = 0; + for (int i = 2; i < block.Length; i++) + { + int predicted = ((s1 * c1) + (s2 * c2)) >> 8; + int signed = Math.Clamp((int)Math.Round((block[i] - predicted) / (double)delta), -8, 7); + int decoded = Math.Clamp(predicted + (signed * delta), short.MinValue, short.MaxValue); + int nibble = signed & 0xF; + int at = 7 + ((i - 2) / 2); + into[at] = (i & 1) == 0 ? (byte)(nibble << 4) : (byte)(into[at] | nibble); + error += (long)(decoded - block[i]) * (decoded - block[i]); + s2 = s1; + s1 = decoded; + delta = Math.Max((AdaptTable[nibble] * delta) >> 8, 16); + } + + return error; + } + + // The 16-byte fmt body every WAV opens with: tag, channels, rate, byte rate, block align, bits. + private static void AddCanonicalFmt(List fmt, short tag, int rate, int byteRate, int blockAlign, int bits) + { + AddShort(fmt, tag); + AddShort(fmt, 1); + fmt.AddRange(BitConverter.GetBytes(rate)); + fmt.AddRange(BitConverter.GetBytes(byteRate)); + AddShort(fmt, (short)blockAlign); + AddShort(fmt, (short)bits); + } + + private static void AddShort(List into, short value) => into.AddRange(BitConverter.GetBytes(value)); + + private static byte[] Riff(params (string Id, byte[] Body)[] chunks) + { + using var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true)) + { + writer.Write(Encoding.ASCII.GetBytes("RIFF")); + writer.Write(0); + writer.Write(Encoding.ASCII.GetBytes("WAVE")); + foreach (var (id, body) in chunks) + { + writer.Write(Encoding.ASCII.GetBytes(id)); + writer.Write(body.Length); + writer.Write(body); + if ((body.Length & 1) == 1) + { + writer.Write((byte)0); + } + } + } + + byte[] file = stream.ToArray(); + BitConverter.TryWriteBytes(file.AsSpan(4), file.Length - 8); + return file; + } +} diff --git a/CSVM/src/Tooling/WavWriter.cs.uid b/CSVM/src/Tooling/WavWriter.cs.uid new file mode 100644 index 000000000..9465651cb --- /dev/null +++ b/CSVM/src/Tooling/WavWriter.cs.uid @@ -0,0 +1 @@ +uid://b7ftj2pn15akg diff --git a/CSVM/src/UI/Boards/SplitScreen.cs b/CSVM/src/UI/Boards/SplitScreen.cs index b9e89d953..38bd67c66 100644 --- a/CSVM/src/UI/Boards/SplitScreen.cs +++ b/CSVM/src/UI/Boards/SplitScreen.cs @@ -30,6 +30,13 @@ public sealed partial class SplitScreen : CanvasLayer /// carries it (), or a pane would be lit twice. public const uint SpyglassSunLayer = 1u << 6; + /// The spyglass disc's flat-sea marker under Enhanced Graphics. No geometry or light is + /// drawn on it, and only the disc's camera carries it + /// (); no pane's cull mask does. A camera + /// carrying it without sees the flat sea: the wave ocean's grid collapses + /// and the base sheet keeps drawing (Mech3.SceneBuilder.FlatSeaEye). + public const uint FlatSeaLayer = 1u << 5; + /// The world's layer 1, which every pane, spyglass and photograph camera draws. A race /// ghost of a seat flown on another machine names it as its owner's layer. No camera here then /// takes that aeroplane for its own, however many seats the race has. @@ -151,14 +158,16 @@ public static Rect2 PaneRect(int index, int players, Vector2 size, bool sideBySi public static string PlayerTag(int index) => $"P{index + 1}"; /// Cull mask for player 's camera: everything outside the - /// reserved per-player band (the shared world, all aircraft) plus only this player's own bit. + /// reserved per-player band (the shared world, all aircraft) plus only this player's own bit. + /// The spyglass disc's two layers are left out. public static uint PlayerCullMask(int index) => - (AllLayers & ~PlayerBand & ~SpyglassSunLayer) | PlayerVisualLayer(index); + (AllLayers & ~PlayerBand & ~SpyglassSunLayer & ~FlatSeaLayer) | PlayerVisualLayer(index); - /// less , with every - /// back, for a pane camera did not - /// build. That is the main camera, which outlives a session whose pilot dropped a bit. - public static uint PaneCullMask(uint mask) => OutsideCullMask(mask) & ~SpyglassSunLayer; + /// less and + /// , with every back, for a pane camera + /// did not build. That is the main camera, which outlives a session + /// whose pilot dropped a bit. + public static uint PaneCullMask(uint mask) => OutsideCullMask(mask) & ~SpyglassSunLayer & ~FlatSeaLayer; /// The visual layer player 's own airframe groups move onto /// while that pilot looks out of the aeroplane (). diff --git a/CSVM/src/UI/Labs/OceanLab.cs b/CSVM/src/UI/Labs/OceanLab.cs new file mode 100644 index 000000000..600c854ed --- /dev/null +++ b/CSVM/src/UI/Labs/OceanLab.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CSVM.Effects; +using CSVM.UI.Boards; +using CSVM.Utils; +using Godot; + +namespace CSVM.UI.Labs; + +/// +/// The ocean lab (Shift+F1), built in --freecam alone. A panel down the right edge holds a +/// slider per sea field (), grouped as the record groups them, with its value +/// and default. An edit reaches the standing ocean on release or a moment after a click, so a drag +/// compiles no shader per frame. Save writes this chapter's entry into the project's +/// ocean_seas.json from the source tree and is off in an exported build. The sliders take no +/// keyboard focus and the freecam looks on the right button, so flying and tuning do not fight. +/// +public sealed partial class OceanLab : Node +{ + // Seconds after a click or wheel step before the edit reaches the ocean; a drag applies on release. + private const float ApplyDelay = 0.25f; + + private const float StatusPeriod = 0.5f; + + private static readonly Color Amber = new(1f, 0.93f, 0.35f); + private static readonly Color Dim = new(0.62f, 0.62f, 0.68f); + + private readonly string _chapter; + private readonly Func _ocean; + private readonly Action _apply; + private readonly string? _savePath; + private readonly List<(SeaState.Field Field, HSlider Slider, Label Value)> _rows = new(); + + private SeaState _saved; + private SeaState _edit; + private CanvasLayer? _layer; + private Label? _status; + private bool _open; + private bool _syncing; + private float _pending = -1f; + private float _statusTimer; + private string _note = ""; + + /// A lab over 's sea: is the file's + /// entry, what the ocean draws now. hands an + /// edit to the session; a null turns Save off. + public OceanLab(string chapter, SeaState saved, SeaState start, Func ocean, Action apply, + string? savePath) + { + _chapter = chapter; + _saved = saved; + _edit = start; + _ocean = ocean; + _apply = apply; + _savePath = savePath; + Name = "ocean_lab"; + } + + /// Open the panel at launch, the open token of --debug-ocean. + public bool StartOpen { get; init; } + + /// Whether the panel is showing. Nothing is built until it first opens. + public bool IsOpen => _open; + + /// The sea the panel holds, as last applied or pending. + public SeaState Edited => _edit; + + /// The chapter's entry as the file holds it, after any Save. + public SeaState Saved => _saved; + + public override void _Ready() + { + if (StartOpen) + { + Toggle(); + return; + } + SetProcess(false); + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Echo: false, Keycode: Key.F1, ShiftPressed: true }) + { + Toggle(); + GetViewport().SetInputAsHandled(); + } + } + + public override void _Process(double delta) + { + if (_pending >= 0f) + { + _pending -= (float)delta; + if (_pending < 0f) + Commit(); + } + if (!_open) + { + SetProcess(_pending >= 0f); + return; + } + _statusTimer += (float)delta; + if (_statusTimer >= StatusPeriod) + { + _statusTimer = 0f; + UpdateStatus(); + } + } + + /// Shows or hides the panel, building it on the first open. + public void Toggle() + { + if (_layer == null) + BuildUi(); + _open = !_open; + _layer!.Visible = _open; + SetProcess(_open || _pending >= 0f); + if (_open) + { + SyncRows(); + UpdateStatus(); + } + Log.Info("ui", $"ocean lab {(_open ? "open" : "closed")} {_chapter} sea={_edit.Describe()}"); + } + + /// Sets one field by its key and applies at once, as a released slider does. + public void Set(string key, float value) + { + var field = SeaState.Find(key) ?? throw new ArgumentException($"'{key}' is not a sea field", nameof(key)); + _edit = field.With(_edit, value); + Commit(); + } + + /// Every field back to the ocean's tune, applied at once. + public void ResetToDefaults() + { + _edit = SeaState.Default; + _note = "reset to the defaults (not saved)"; + Commit(); + } + + /// Every field back to the file's entry, applied at once. + public void RevertToSaved() + { + _edit = _saved; + _note = "reverted to the saved sea"; + Commit(); + } + + /// Writes this chapter's entry into the source tree's file, the fields that differ from + /// the defaults alone. Returns the path written, or null where Save is off, the file there is not + /// readable JSON, or the write failed. + public string? Save() + { + if (_savePath == null) + { + _note = "Save is off: an exported build carries its seas inside the pck"; + UpdateStatus(); + return null; + } + Commit(); + try + { + int fields = OceanSeas.Save(_savePath, _chapter, _edit); + _saved = _edit; + _note = $"saved {_chapter}, {fields} field(s) off the defaults, to {_savePath}"; + Log.Info("ui", $"ocean lab: saved {_chapter} sea={_edit.Describe()} to {_savePath}"); + UpdateStatus(); + return _savePath; + } + catch (InvalidDataException ex) + { + _note = $"save refused: {ex.Message}; fix or delete {_savePath} first"; + Log.Warn("ui", $"ocean lab: left {_savePath} unwritten, {ex.Message}"); + UpdateStatus(); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _note = $"save failed: {ex.Message}"; + Log.Error("ui", $"ocean lab: could not write {_savePath}", ex); + UpdateStatus(); + return null; + } + } + + private static Label Small(string text, Color? colour = null) + { + var label = new Label { Text = text, Modulate = colour ?? new Color(1, 1, 1, 0.75f), AutowrapMode = TextServer.AutowrapMode.WordSmart }; + label.AddThemeFontSizeOverride("font_size", 11); + return label; + } + + // Clamps the edit, hands it to the session and shows what the ocean now draws. + private void Commit() + { + _pending = -1f; + _edit = _edit.Clamped(); + _apply(_edit); + SyncRows(); + UpdateStatus(); + Log.Info("ui", $"ocean lab: {_chapter} sea={_edit.Describe()}"); + } + + private void BuildUi() + { + _layer = new CanvasLayer { Layer = HudLayers.Lab, Visible = false }; + var root = new Control { MouseFilter = Control.MouseFilterEnum.Ignore }; + root.SetAnchorsPreset(Control.LayoutPreset.FullRect); + + // Down the right edge, clear of the freecam readout above; the node lab owns the left. + var panel = new PanelContainer { SelfModulate = new Color(1, 1, 1, 0.88f) }; + panel.SetAnchorsPreset(Control.LayoutPreset.RightWide); + panel.OffsetLeft = -392; + panel.OffsetRight = -8; + panel.OffsetTop = 40; + panel.OffsetBottom = -16; + + var margin = new MarginContainer(); + foreach (string side in new[] { "margin_left", "margin_right", "margin_top", "margin_bottom" }) + margin.AddThemeConstantOverride(side, 8); + var box = new VBoxContainer(); + box.AddThemeConstantOverride("separation", 4); + box.AddChild(new Label { Text = $"OCEAN LAB {_chapter}", Modulate = Amber }); + + var scroll = new ScrollContainer + { + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + SizeFlagsVertical = Control.SizeFlags.ExpandFill, + }; + var rows = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + rows.AddThemeConstantOverride("separation", 2); + string group = ""; + foreach (var field in SeaState.Fields) + { + if (field.Group != group) + { + group = field.Group; + rows.AddChild(new Label { Text = group.ToUpperInvariant(), Modulate = Amber }); + } + AddRow(rows, field); + } + scroll.AddChild(rows); + box.AddChild(scroll); + + var buttons = new HBoxContainer(); + buttons.AddThemeConstantOverride("separation", 4); + buttons.AddChild(Btn("Reset to defaults", ResetToDefaults)); + buttons.AddChild(Btn("Revert to saved", RevertToSaved)); + var save = Btn($"Save for {_chapter}", () => Save()); + save.Disabled = _savePath == null; + buttons.AddChild(save); + box.AddChild(buttons); + if (_savePath == null) + box.AddChild(Small("Save is off in an exported build: its seas live inside the pck.", Dim)); + _status = Small(""); + box.AddChild(_status); + box.AddChild(Small("RMB looks, the sliders take the left button · Shift+F1 hides this panel", Dim)); + + margin.AddChild(box); + panel.AddChild(margin); + root.AddChild(panel); + _layer.AddChild(root); + AddChild(_layer); + } + + private void AddRow(VBoxContainer rows, SeaState.Field field) + { + var head = new HBoxContainer(); + var name = Small(field.Label); + name.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + name.AutowrapMode = TextServer.AutowrapMode.Off; + head.AddChild(name); + var value = Small(""); + value.AutowrapMode = TextServer.AutowrapMode.Off; + head.AddChild(value); + rows.AddChild(head); + + // No keyboard focus, so the arrow keys and WASD keep flying the camera after a click. + var slider = new HSlider + { + MinValue = field.Min, + MaxValue = field.Max, + Step = field.Step, + Value = field.Get(_edit), + FocusMode = Control.FocusModeEnum.None, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + }; + slider.ValueChanged += v => + { + if (_syncing) + return; + _edit = field.With(_edit, (float)v); + value.Text = ValueText(field); + _pending = ApplyDelay; + SetProcess(true); + }; + slider.DragEnded += changed => + { + if (changed) + Commit(); + }; + rows.AddChild(slider); + if (field.Note.Length > 0) + rows.AddChild(Small(field.Note, Dim)); + _rows.Add((field, slider, value)); + } + + private string ValueText(SeaState.Field field) => + $"{field.Format(field.Get(_edit))} (default {field.Format(field.Default)})"; + + // The sliders and their readouts from the edit, which a clamp or a reset may have moved. + private void SyncRows() + { + _syncing = true; + foreach (var (field, slider, value) in _rows) + { + slider.Value = field.Get(_edit); + value.Text = ValueText(field); + } + _syncing = false; + } + + private void UpdateStatus() + { + if (_status == null) + return; + string ocean = _ocean() is { } o && IsInstanceValid(o) && o.IsInsideTree() + ? "ocean standing" + : "no ocean standing (Original, flat water or --no-ocean); it takes these values when built"; + string saved = _edit == _saved ? "matches the saved sea" : "unsaved changes"; + _status.Text = $"{ocean} · {saved}{(_note.Length > 0 ? $"\n{_note}" : "")}"; + } + + private Button Btn(string text, Action pressed) + { + // FocusMode None keeps a clicked button from swallowing the camera's keys afterwards. + var b = new Button { Text = text, FocusMode = Control.FocusModeEnum.None }; + b.Pressed += () => + { + GetViewport().GuiReleaseFocus(); + pressed(); + }; + return b; + } +} diff --git a/CSVM/src/UI/Labs/OceanLab.cs.uid b/CSVM/src/UI/Labs/OceanLab.cs.uid new file mode 100644 index 000000000..adc39acce --- /dev/null +++ b/CSVM/src/UI/Labs/OceanLab.cs.uid @@ -0,0 +1 @@ +uid://cchnr4a3155ra diff --git a/CSVM/src/UI/Menu/MenuExit.cs b/CSVM/src/UI/Menu/MenuExit.cs index 5322caa06..197dba27b 100644 --- a/CSVM/src/UI/Menu/MenuExit.cs +++ b/CSVM/src/UI/Menu/MenuExit.cs @@ -39,7 +39,7 @@ public sealed record QuitExit : MenuExit; /// command line's, never an option. Carried are a word the apply /// switches the running world to, a the next launch /// reads, and 's other fields, null where never set. A screen showing none of them -/// hands back what it read, since the consumer writes every field it is given. ⚠ All eighteen ride +/// hands back what it read, since the consumer writes every field it is given. ⚠ All nineteen ride /// the exit, not the screen's own save, so the options file keeps one writer, and none is /// defaulted. ⚠ A field no screen offers is dropped rather than left riding as a null, since a null /// the consumer saves wipes the file's value. @@ -61,7 +61,8 @@ public sealed record OptionsApplyExit( bool? Rumble, string? DefaultView, bool? AutoHeadTurn, - string? ViewDistance) : MenuExit; + string? ViewDistance, + string? WaterQuality) : MenuExit; /// Dogfight's match rules as a screen set them. The kill target ends a match early and the /// match clock runs in MINUTES, a 0 on either disabling that limit. The lives are the deaths a pilot diff --git a/CSVM/src/UI/Menu/Original/OriginalOptionsScreen.cs b/CSVM/src/UI/Menu/Original/OriginalOptionsScreen.cs index 032035e61..6081c7e2a 100644 --- a/CSVM/src/UI/Menu/Original/OriginalOptionsScreen.cs +++ b/CSVM/src/UI/Menu/Original/OriginalOptionsScreen.cs @@ -266,7 +266,8 @@ OptionsApplyExit IOriginalOptionsForm.Apply() => Video.RenderScaleChoice, Video.AntiAliasingChoice, Video.ShadowQualityChoice, Audio.MasterChoice, Audio.MusicChoice, Audio.EffectsChoice, Audio.VoiceChoice, GameOptions.NearestAfterKillChoice, GameOptions.RumbleChoice, - GameOptions.DefaultViewChoice, GameOptions.AutoHeadTurnChoice, Video.ViewDistanceChoice); + GameOptions.DefaultViewChoice, GameOptions.AutoHeadTurnChoice, Video.ViewDistanceChoice, + Video.WaterQualityChoice); // Back from a page: the saved settings are read again, so an edit the player declined is gone. void IOriginalOptionsForm.Leave() diff --git a/CSVM/src/UI/Menu/Original/OriginalVideoPage.cs b/CSVM/src/UI/Menu/Original/OriginalVideoPage.cs index ba13feff7..20ea28151 100644 --- a/CSVM/src/UI/Menu/Original/OriginalVideoPage.cs +++ b/CSVM/src/UI/Menu/Original/OriginalVideoPage.cs @@ -125,6 +125,9 @@ public sealed class OriginalVideoPage : IOriginalOptionsPage // The view distance as saved, null while never set, which resolves to Normal. No row shows it; // the apply carries it unchanged. private string? _viewDistance; + // The water quality as saved, null while never set. No row shows it either; the apply carries + // it unchanged. + private string? _waterQuality; private string? _monitorIndex; private string? _resolution; // The size the options file named when the page last read it, which the size row offers as an @@ -177,6 +180,10 @@ internal OriginalVideoPage( /// The view-distance word the page would apply, null while never set. public string? ViewDistanceChoice => _viewDistance; + /// The water-quality word the page would apply, which is the saved one, null while never + /// set. + public string? WaterQualityChoice => _waterQuality; + /// The screen index ('s spelling) the /// page would apply, or null while nothing has been saved and no row has been touched. public string? MonitorChoice => _monitorIndex; @@ -420,6 +427,7 @@ internal void Read(CSVM.Utils.OptionsDef? saved) { _graphics = saved?.GraphicsMode ?? CSVM.Utils.GraphicsMode.Default; _viewDistance = saved?.ViewDistance; + _waterQuality = saved?.WaterQuality; _monitorIndex = saved?.MonitorIndex; _resolution = saved?.Resolution; _savedResolution = saved?.Resolution; diff --git a/CSVM/src/UI/Menu/Original/SyntheticShell.cs b/CSVM/src/UI/Menu/Original/SyntheticShell.cs new file mode 100644 index 000000000..02ae7213c --- /dev/null +++ b/CSVM/src/UI/Menu/Original/SyntheticShell.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using CSVM.Extraction; +using CSVM.Tooling; + +namespace CSVM.UI.Menu.Original; + +/// +/// The synthetic tree's Original shell: the extracted/rof/ files its availability check and +/// its screens read. The hand-authored fixtures/menu-layout-original/LAYOUT.CSV goes through +/// the extraction's own decoder into menu_layout.json, joined to the invented string rows of +/// fixtures/synthetic/rof/ui_strings.json. A row with a symbol is a layout string; one +/// without is a row the code reads by id. Every art file is generated at the size +/// fixtures/synthetic/rof/art.json gives it, so a widget's size is a record and its pixels +/// are code. A file the manifest requires and the record does not size stops the build. +/// +public static class SyntheticShell +{ + /// Every family of the synthetic tree, the tooling ones and then this one. A tooling + /// family may not name a UI type, so the whole list is composed here, the lowest family that + /// names both. Every caller of hands it in. + public static readonly IReadOnlyList TreeFamilies = + SyntheticData.Families.Append(new SyntheticFamily("original-shell", WriteShell)).ToArray(); + + private const string LayoutFixture = "menu-layout-original/LAYOUT.CSV"; + private const string StringsFixture = "synthetic/rof/ui_strings.json"; + private const string ArtFixture = "synthetic/rof/art.json"; + + /// Writes the shell's files under . + public static void WriteShell(SyntheticTree tree) + { + string strings = tree.CopyFixture(StringsFixture, ExtractionRun.RofFolder + "/ui_strings.json"); + var rows = Rows(strings); + var art = Art(tree.Fixture(ArtFixture)); + var input = new MenuLayoutInput + { + LayoutCsv = File.ReadAllText(tree.Fixture(LayoutFixture), Encoding.ASCII), + ResourceHeader = string.Concat(rows.Where(r => r.Symbol != null).Select(r => $"#define {r.Symbol} {r.Id}\n")), + Strings = rows.ToDictionary(r => r.Id, r => r.Text), + ArchiveFiles = art.Keys.Select(name => "GRAPHICS/" + name).ToList(), + }; + string json = MenuLayoutDecoder.ToJson(MenuLayoutDecoder.Decode(input)); + File.WriteAllText(tree.Under(ExtractionRun.RofFolder + "/menu_layout.json"), json, new UTF8Encoding(false)); + + // A required file with no size would pass the build and refuse Original at run time, far + // from the record that lacks it. + var manifest = OriginalAssetManifest.Derive(MenuLayout.Parse(json)); + foreach (var asset in manifest.Assets) + { + if (asset.Need == OriginalAssetNeed.Required && !OriginalAvailability.IsMovie(asset.Name) && !art.ContainsKey(asset.Name)) + { + throw new InvalidDataException($"{ArtFixture} gives no size for {asset.Name}, which [{asset.Section}] {asset.Row} requires"); + } + } + + foreach (var (name, (width, height)) in art) + { + tree.WriteBytes(ExtractionRun.RofFolder + "/" + OriginalAvailability.RelativeArtPath(name), Encode(name, width, height)); + } + } + + // The picture in the format its extension names, since a loader picks its decoder that way. + private static byte[] Encode(string name, int width, int height) => + Path.GetExtension(name).ToLowerInvariant() switch + { + ".png" => PngWriter.EncodeRgb(width, height, SyntheticTextures.Checker(name, width, height)), + ".jpg" => SyntheticImages.Jpeg(name, width, height), + ".tga" => SyntheticImages.Tga(name, width, height), + _ => throw new InvalidDataException($"{ArtFixture}: {name} is not a picture this tree can write"), + }; + + private static List Rows(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8)); + var rows = new List(); + foreach (var row in doc.RootElement.EnumerateArray()) + { + rows.Add(new UiStringRow( + row.GetProperty("id").GetInt32(), + row.GetProperty("symbol").GetString(), + null, + row.GetProperty("text").GetString() ?? string.Empty, + row.GetProperty("dll").GetString() ?? string.Empty)); + } + + return rows; + } + + // Name to size, in record order. A missing field is an error, not a default. + private static Dictionary Art(string path) + { + using var doc = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8)); + var art = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in doc.RootElement.GetProperty("art").EnumerateArray()) + { + string name = entry.GetProperty("name").GetString() ?? throw new InvalidDataException($"{path}: an entry has no name"); + if (!art.TryAdd(name, (entry.GetProperty("width").GetInt32(), entry.GetProperty("height").GetInt32()))) + { + throw new InvalidDataException($"{path}: {name} is listed twice"); + } + } + + return art; + } +} diff --git a/CSVM/src/UI/Menu/Original/SyntheticShell.cs.uid b/CSVM/src/UI/Menu/Original/SyntheticShell.cs.uid new file mode 100644 index 000000000..8c4e7582e --- /dev/null +++ b/CSVM/src/UI/Menu/Original/SyntheticShell.cs.uid @@ -0,0 +1 @@ +uid://db6dja20v1acl diff --git a/CSVM/src/UI/Screens/LaunchMenu.cs b/CSVM/src/UI/Screens/LaunchMenu.cs index b654fa805..fbafabb87 100644 --- a/CSVM/src/UI/Screens/LaunchMenu.cs +++ b/CSVM/src/UI/Screens/LaunchMenu.cs @@ -178,13 +178,12 @@ public sealed partial class LaunchMenu : CanvasLayer // in its order: reset the keymap, abandon the staged edits, commit them. Accept stays last. TUNE. private const int ControlsFooterRows = 4; private const int ControlsFolderButton = 0; - // The Options screen's stepper rows, above the Controls door and the apply row. The screen - // is a form the cursor walks top to bottom. First the five gameplay settings: the three the - // Original presentation's GAME OPTIONS page draws, in its order, then the targeting switch - // and the rumble. Then the graphics mode, its view distance, the six display settings and the - // shadow quality in the order the Original presentation's VIDEO page draws them. Then the four - // volume levels in the order its AUDIO page draws them, then the two doors. - private const int OptionsStepperRows = 18; + // The Options screen's stepper rows, above the Controls door and the apply row, walked top to + // bottom. First the three GAME OPTIONS settings in that page's order, then the targeting switch + // and the rumble. Then the graphics mode and its view distance. Then the six display + // settings and the shadow quality in the VIDEO page's order, then the water quality. Then the + // four volume levels in the AUDIO page's order, then the two doors. + private const int OptionsStepperRows = 19; // How many Options rows show at once. Twenty rows do not fit the band at 720p, and a band // sized to all of them shrinks every row. The screen is windowed at the Controls list's // height, which is known to fit. @@ -322,6 +321,8 @@ public sealed partial class LaunchMenu : CanvasLayer private bool? _autoHeadTurnChoice; private string _graphicsChoice = GraphicsMode.Default; private string? _viewDistanceChoice; + // The water quality as saved, null while never set, which shows and applies the run's own word. + private string? _waterQualityChoice; // The six display settings, stepped by the six rows under the view distance. Each is stored as // the word the options file carries, never as a row index. A screen unplugged or a size the // monitor stopped offering then meets the resolver's own forgiving read, not a stale position. @@ -1872,7 +1873,7 @@ private bool HandleMoveX(int dir) switch (_screen) { case Screen.Options: - // The eighteen choice rows are steppers; the doors under them have nothing to step. + // The nineteen choice rows are steppers; the doors under them have nothing to step. switch (_optionsIndex) { case 0: StepDifficultyChoice(dir); return true; @@ -1889,10 +1890,11 @@ private bool HandleMoveX(int dir) case 11: StepRenderScaleChoice(dir); return true; case 12: StepAntiAliasingChoice(dir); return true; case 13: StepShadowQualityChoice(dir); return true; - case 14: _audioMasterChoice = StepLevel(_audioMasterChoice, AudioMix.DefaultMaster, dir); return true; - case 15: _audioMusicChoice = StepLevel(_audioMusicChoice, AudioMix.DefaultMusic, dir); return true; - case 16: _audioEffectsChoice = StepLevel(_audioEffectsChoice, AudioMix.DefaultEffects, dir); return true; - case 17: _audioVoiceChoice = StepLevel(_audioVoiceChoice, AudioMix.DefaultVoice, dir); return true; + case 14: StepWaterQualityChoice(dir); return true; + case 15: _audioMasterChoice = StepLevel(_audioMasterChoice, AudioMix.DefaultMaster, dir); return true; + case 16: _audioMusicChoice = StepLevel(_audioMusicChoice, AudioMix.DefaultMusic, dir); return true; + case 17: _audioEffectsChoice = StepLevel(_audioEffectsChoice, AudioMix.DefaultEffects, dir); return true; + case 18: _audioVoiceChoice = StepLevel(_audioVoiceChoice, AudioMix.DefaultVoice, dir); return true; default: return false; } case Screen.Network: @@ -2024,7 +2026,7 @@ private void HandleAccept() Difficulty.Word(_difficultyChoice), _monitorChoice, _resolutionChoice, _displayModeChoice, _vsyncChoice, _renderScaleChoice, _antiAliasingChoice, _shadowQualityChoice, _audioMasterChoice, _audioMusicChoice, _audioEffectsChoice, _audioVoiceChoice, _nearestAfterKillChoice, - _rumbleChoice, _defaultViewChoice, _autoHeadTurnChoice, _viewDistanceChoice)); + _rumbleChoice, _defaultViewChoice, _autoHeadTurnChoice, _viewDistanceChoice, _waterQualityChoice)); } break; @@ -3468,6 +3470,7 @@ private void OpenOptions() _renderScaleChoice = saved.RenderScale; _antiAliasingChoice = saved.AntiAliasing; _shadowQualityChoice = saved.ShadowQuality; + _waterQualityChoice = saved.WaterQuality; _audioMasterChoice = saved.AudioMaster; _audioMusicChoice = saved.AudioMusic; _audioEffectsChoice = saved.AudioEffects; @@ -3951,6 +3954,25 @@ private void StepShadowQualityChoice(int dir) _shadowQualityChoice = words[DisplaySettingRows.Step(at, dir, words.Count)]; } + // Dead under Original, whose sea is the flat sheet whatever the word. The saved word is kept for + // a later flip, as the shadow row keeps its own. + private void StepWaterQualityChoice(int dir) + { + if (_graphicsChoice != GraphicsMode.EnhancedWord) + { + return; + } + + var words = WaterQualitySetting.Words; + int at = DisplaySettingRows.WordIndex(words, _waterQualityChoice, WaterQualitySetting.Word); + _waterQualityChoice = words[DisplaySettingRows.Step(at, dir, words.Count)]; + } + + // The row says why it does not step rather than refusing in silence. + private string WaterQualityDetail() => _graphicsChoice == GraphicsMode.EnhancedWord + ? "Flat draws the original's sea; Waves draws a swell in its place. Flat runs faster. Applies at once." + : "Enhanced Graphics only: the original world's sea is always flat."; + // The size row's detail says what the size does under the mode standing with it. The size does // something different in each mode, and under borderless the row does not step at all. A // stepper that refuses without saying why reads as a broken row. @@ -4504,11 +4526,12 @@ private string RowText(int index) 11 => $"Render scale: {RenderScaleChoiceLabel()}", 12 => $"Anti-aliasing: {AntiAliasingChoiceLabel()}", 13 => $"Shadow quality: {ShadowQualityChoiceLabel()}", - 14 => $"Master volume: {LevelLabel(_audioMasterChoice, AudioMix.DefaultMaster)}", - 15 => $"Music volume: {LevelLabel(_audioMusicChoice, AudioMix.DefaultMusic)}", - 16 => $"Effects volume: {LevelLabel(_audioEffectsChoice, AudioMix.DefaultEffects)}", - 17 => $"Voice volume: {LevelLabel(_audioVoiceChoice, AudioMix.DefaultVoice)}", - 18 => ControlsRow, + 14 => $"Water quality: {WaterQualitySetting.Label(_waterQualityChoice)}", + 15 => $"Master volume: {LevelLabel(_audioMasterChoice, AudioMix.DefaultMaster)}", + 16 => $"Music volume: {LevelLabel(_audioMusicChoice, AudioMix.DefaultMusic)}", + 17 => $"Effects volume: {LevelLabel(_audioEffectsChoice, AudioMix.DefaultEffects)}", + 18 => $"Voice volume: {LevelLabel(_audioVoiceChoice, AudioMix.DefaultVoice)}", + 19 => ControlsRow, _ => "Apply and restart the menu", }, Screen.Controls => $"{ControlsRowLabel(index)} {ControlsRowValue(index)}", @@ -4919,11 +4942,12 @@ private string PresetCrumb() => 11 => "Render the world below native to spare the GPU, or above it for cleaner edges. Applies at once.", 12 => "Select how edges are smoothed. FSR 2.2 also upscales a Render Scale below 100%. Applies at once.", 13 => DisplaySettingRows.ShadowQualityDetail(_graphicsChoice), - 14 => "Set the overall volume of all sounds. Heard once the choices are applied.", - 15 => "Set the volume of the in-game music. Heard once the choices are applied.", - 16 => "Set the volume of the sound effects. Heard once the choices are applied.", - 17 => "Set the volume of the voices. Heard once the choices are applied.", - 18 => "Rebind any control, per player. Saved on the way out; the shipped keymap is one press away.", + 14 => WaterQualityDetail(), + 15 => "Set the overall volume of all sounds. Heard once the choices are applied.", + 16 => "Set the volume of the in-game music. Heard once the choices are applied.", + 17 => "Set the volume of the sound effects. Heard once the choices are applied.", + 18 => "Set the volume of the voices. Heard once the choices are applied.", + 19 => "Rebind any control, per player. Saved on the way out; the shipped keymap is one press away.", _ => "Saves every choice and restarts the menu at its top level; unfinished setup is discarded.", }, Screen.Controls => ControlsDetail(focus), diff --git a/CSVM/src/Utils/OptionsStore.cs b/CSVM/src/Utils/OptionsStore.cs index d02f69501..366ccf3f6 100644 --- a/CSVM/src/Utils/OptionsStore.cs +++ b/CSVM/src/Utils/OptionsStore.cs @@ -212,6 +212,10 @@ public sealed class OptionsDef /// enhanced mode's own look. public string? ShadowQuality { get; set; } + /// The Enhanced sea, one of . Null is "never + /// set", which reads as the machine's own fallback: waves on a desktop, flat on the Deck. + public string? WaterQuality { get; set; } + /// The Master level, the multiplier over the three category levels /// (). No vocabulary and no spelling to parse, so the store proves the /// range alone. @@ -333,6 +337,8 @@ public sealed class OptionsStore private static readonly HashSet ValidShadowQualities = new(ShadowQualitySetting.Words, StringComparer.Ordinal); + private static readonly HashSet ValidWaterQualities = new(WaterQualitySetting.Words, StringComparer.Ordinal); + private static readonly JsonWriterOptions WriterOptions = new() { Indented = true }; private readonly string _dir; @@ -386,6 +392,7 @@ public static string Serialize(OptionsDef def) Write(w, "renderScale", def.RenderScale); Write(w, "antiAliasing", def.AntiAliasing); Write(w, "shadowQuality", def.ShadowQuality); + Write(w, "waterQuality", def.WaterQuality); WriteLevel(w, "audioMaster", def.AudioMaster); WriteLevel(w, "audioMusic", def.AudioMusic); WriteLevel(w, "audioEffects", def.AudioEffects); @@ -463,6 +470,7 @@ public static string Serialize(OptionsDef def) RenderScale = Read(root, "renderScale", ValidRenderScales), AntiAliasing = Read(root, "antiAliasing", ValidAntiAliasing), ShadowQuality = Read(root, "shadowQuality", ValidShadowQualities), + WaterQuality = Read(root, "waterQuality", ValidWaterQualities), AudioMaster = ReadLevel(root, "audioMaster"), AudioMusic = ReadLevel(root, "audioMusic"), AudioEffects = ReadLevel(root, "audioEffects"), diff --git a/CSVM/src/Utils/ShadowQualitySetting.cs b/CSVM/src/Utils/ShadowQualitySetting.cs index fa8664a01..c9ed5a432 100644 --- a/CSVM/src/Utils/ShadowQualitySetting.cs +++ b/CSVM/src/Utils/ShadowQualitySetting.cs @@ -99,9 +99,13 @@ public static class ShadowQualitySetting /// names a splitscreen session's count. public static int Panes { get; private set; } = 1; - /// The fallback word for this machine at the running session's . - /// The GPU is read once, since the device cannot change under a run. - public static string MachineDefault => FallbackFor(_integrated ??= IsIntegratedGpu(), Panes); + /// Whether the renderer runs on an integrated GPU, the Steam Deck's APU among them. Read + /// once, since the device cannot change under a run. 's fallback + /// reads it too. + public static bool IntegratedGpu => _integrated ??= IsIntegratedGpu(); + + /// The fallback word for this machine at the running session's . + public static string MachineDefault => FallbackFor(IntegratedGpu, Panes); /// The fallback a launch resolves against. ⚠ under /// , so a scripted capture does not depend on the machine it runs on. diff --git a/CSVM/src/Utils/WaterQualitySetting.cs b/CSVM/src/Utils/WaterQualitySetting.cs new file mode 100644 index 000000000..c59da7581 --- /dev/null +++ b/CSVM/src/Utils/WaterQualitySetting.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +namespace CSVM.Utils; + +/// +/// The Enhanced sea's water quality, one of : the flat sea-level sheet, or the +/// wave ocean (Effects.Ocean) in its place. In , --water-quality= +/// beats the saved waterQuality option, which beats the config key, then the +/// fallback. That is on a desktop and on Linux or an +/// integrated GPU (), so the Steam Deck runs flat. Under --det only +/// the flag survives, over . The faithful path draws the flat sheet whatever the +/// word. The Built-in Options screen writes the option, applied live through the session. +/// +public static class WaterQualitySetting +{ + /// The config key under the saved option, one of . + public const string Key = "graphics.waterQuality"; + + /// The flat sea-level sheet, as the faithful path draws it. + public const string Flat = "flat"; + + /// The wave ocean over the sea-level sheet's place. + public const string Waves = "waves"; + + /// The word a desktop with nothing saved runs, and under --det every machine. + public const string Default = Waves; + + /// The word Linux and an integrated GPU run when nothing is set. The ocean's wave grid + /// costs a low-power GPU several milliseconds a frame over the flat sheet, more than the Deck can + /// spare. + public const string LowPowerDefault = Flat; + + /// Every word, the cheaper first, the order the Options row offers them. + public static readonly IReadOnlyList Words = new[] { Flat, Waves }; + + /// What the Options row shows for each of , index for index. + public static readonly IReadOnlyList Labels = new[] { "Flat", "Waves" }; + + /// The source order over , beaten by --water-quality=. + public static readonly WordSetting Lookup = new(Key, Words, "--water-quality"); + + /// The word the run resolved, until runs. The + /// Options row shows it when nothing is saved, and the session's ocean follows it. + public static string Word { get; private set; } = Default; + + /// Whether the resolved word asks for the wave ocean. + public static bool DrawsWaves => Word == Waves; + + /// The fallback word for this machine. ⚠ Asks the renderer for its GPU class, so a unit + /// test reads instead. + public static string MachineDefault => FallbackFor(OperatingSystem.IsLinux(), ShadowQualitySetting.IntegratedGpu); + + /// The fallback a launch resolves against. ⚠ under + /// , so a scripted capture does not depend on the machine it runs on. + public static string DefaultFor(bool det) => det ? Default : MachineDefault; + + /// The fallback on a machine that is or is not running , on a GPU + /// that is or is not . Either one takes . + /// Linux stands for the Deck, whose SteamOS reports Linux whatever its GPU reads as. + public static string FallbackFor(bool linux, bool integrated) => linux || integrated ? LowPowerDefault : Default; + + /// The word resolves the sources to, stored as . + public static ResolvedWord Resolve(string? flagWord, string? savedWord, string? configWord, + string fallback = Default) + { + var resolved = Pick(flagWord, savedWord, configWord, fallback); + Word = resolved.Word; + return resolved; + } + + /// The word resolves the sources to over , + /// leaving alone. A fallback carries its own + /// source, so the log line says the machine chose flat. + public static ResolvedWord Pick(string? flagWord, string? savedWord, string? configWord, + string fallback = Default) => + Lookup.Resolve(flagWord, savedWord, configWord, fallback, + fallback == LowPowerDefault ? SettingSource.LowPowerDefault : SettingSource.Default); + + /// The launch's ladder: , then the saved word and the config + /// key, which --det both drop, over . An Options apply passes the + /// word it applied as . + public static ResolvedWord ResolveForLaunch(string? flagWord, string? savedWord, bool det) + { + string fallback = DefaultFor(det); + return Resolve(flagWord, savedWord, Config.GetString(Key, fallback), fallback); + } + + /// The saved word a launch reads, or null under + /// (). + public static string? SavedWord(bool det) => WordSetting.ReadSaved(det, static o => o.WaterQuality); + + /// The label the Options row shows for , or for + /// where the word is null or unknown. + public static string Label(string? word) + { + int at = Lookup.IndexOf(word); + return Labels[at >= 0 ? at : Math.Max(0, Lookup.IndexOf(Word))]; + } +} diff --git a/CSVM/src/Utils/WaterQualitySetting.cs.uid b/CSVM/src/Utils/WaterQualitySetting.cs.uid new file mode 100644 index 000000000..2e9e4c18b --- /dev/null +++ b/CSVM/src/Utils/WaterQualitySetting.cs.uid @@ -0,0 +1 @@ +uid://b85utsewf4su5 diff --git a/CSVM/src/Utils/WordSetting.cs b/CSVM/src/Utils/WordSetting.cs index b7f738452..eb7186067 100644 --- a/CSVM/src/Utils/WordSetting.cs +++ b/CSVM/src/Utils/WordSetting.cs @@ -24,6 +24,9 @@ public enum SettingSource /// Shadow quality's fallback on an integrated GPU drawing three or more panes. IntegratedGpuPanesDefault, + + /// Water quality's fallback on Linux or on an integrated GPU, the Steam Deck being both. + LowPowerDefault, } /// One resolved word and the source that won. @@ -33,9 +36,10 @@ public enum SettingSource /// The lookup a word-valued graphics setting shares. Highest first, it reads the setting's flag, the /// saved word, the config key, then a fallback the setting chooses. A word outside /// reads as never set and falls through. Each setting keeps its own rules beside -/// this: 's mode-dependent fallback, 's -/// FSR 2.2 clamp, 's GPU and pane fallback, and -/// 's reach. Engine-free, so the order tests without a renderer. +/// this, such as the mode-dependent fallback in and the FSR 2.2 clamp +/// in . Others are the GPU fallback in , +/// the platform fallback in and the reach in . +/// Engine-free, so the order tests without a renderer. /// public sealed class WordSetting { @@ -122,6 +126,7 @@ public ResolvedWord Resolve(string? flagWord, string? savedWord, string? configW SettingSource.Config => Key, SettingSource.IntegratedGpuDefault => "default_integrated_gpu", SettingSource.IntegratedGpuPanesDefault => "default_integrated_gpu_panes", + SettingSource.LowPowerDefault => "default_linux_or_integrated_gpu", _ => "default", }; } diff --git a/CheckCommentCaps.ps1 b/CheckCommentCaps.ps1 index 5334530f2..92780979f 100644 --- a/CheckCommentCaps.ps1 +++ b/CheckCommentCaps.ps1 @@ -93,7 +93,7 @@ function Get-Scope { } } # Generated trees are not ours. - $files | Where-Object { $_.FullName -notmatch '\\(obj|bin|\.godot)\\' } | ForEach-Object { $_.FullName } + $files | Where-Object { $_.FullName -notmatch '[\\/](obj|bin|\.godot)[\\/]' } | ForEach-Object { $_.FullName } } # Which cap applies to the block ending at $end, and what it is attached to. diff --git a/CheckDocEntries.ps1 b/CheckDocEntries.ps1 index b4388e82f..48def73da 100644 --- a/CheckDocEntries.ps1 +++ b/CheckDocEntries.ps1 @@ -200,7 +200,7 @@ if (Test-Path -LiteralPath $srcRoot) { ForEach-Object { $_.Value } | Select-Object -Unique) $allCs = @(Get-ChildItem -LiteralPath $srcRoot -Filter *.cs -Recurse -File) - $allCs = $allCs | Where-Object { $_.FullName -notmatch '\\(obj|bin|\.godot)\\' } + $allCs = $allCs | Where-Object { $_.FullName -notmatch '[\\/](obj|bin|\.godot)[\\/]' } foreach ($cs in $allCs) { $rel = $cs.FullName.Substring($srcRoot.Length + 1).Replace('\', '/') if ($rel -match '(^|/)Testing/[^/]*Suites\.cs$') { continue } diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 7914fdc88..3bb588daa 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -105,6 +105,7 @@ One line each, **the extraction pipeline, the launch scripts and the mech3ax for - `Extract.ps1`, the developer's extraction wrapper: builds, then runs the engine headless with `--extract=` (install `CrimsonSkiesGame`, fork unzbd, data root the script's own folder unless given). It holds no extraction logic; the pipeline is `CSVM/src/Extraction/`, which the in-game Extract screen runs too. Details: `docs/tooling.md`. - `RunGame.ps1` / `RunDev.ps1`, play and dev launch scripts (build + Godot; dev one prompts). Details: `docs/tooling.md`. - `RunTests.ps1`, one command, one exit code: build → `dotnet test` → `--run-tests` (`-Shards`, default 4) → goldens (`-GoldenWorkers`, default 4) → perf (`-Perf`, A/B'd via the git-ignored `perf-history.jsonl`) → hitch (`-Hitch`, opt-in and last). Details: `docs/tooling.md`. +- `RunCiSuites.ps1`, CI's engine stage, runnable on Linux or macOS: `--run-tests=tier:ci` headless over the `--synthetic-data` tree, judged from the report. Details: `docs/tooling.md`. - `ExportRelease.ps1`, builds, headless-imports, and exports the "Windows Desktop" release preset to `.scratch/export/CSVM.exe`; checks the export templates are installed and creates `.scratch/export/` if missing before starting. Details: `docs/tooling.md`. - `PublishRelease.ps1`, the publish in one run: version, `ExportRelease.ps1 -Linux`, `sandbox/LinuxRelease.ps1` on the tarball, both archives' SHA-256s, the annotated tag on the commit that was built, and the GitHub release carrying the zip and the tarball (`-NotesFile` for the prose above the generated sections, `-DryRun`, `-TagSuffix` for a rehearsal run). Refuses a dirty tree, a dirty or unpushed `cs-anim`, or an existing tag, and never re-points one. Details: `docs/tooling.md`. - `RunSandbox.ps1`, runs a release zip in a clean Windows Sandbox and collects what happened (`-NoVGpu` is the below-the-renderer-floor machine, `-Driver` chooses what runs inside, `-MapReadOnly` adds host folders the zip does not carry). Details: `docs/tooling.md`. diff --git a/RunCiSuites.ps1 b/RunCiSuites.ps1 new file mode 100644 index 000000000..a67aa5eb0 --- /dev/null +++ b/RunCiSuites.ps1 @@ -0,0 +1,226 @@ +<# +.SYNOPSIS + The CI engine stage: the in-engine suites a selector names (default the ci tier), run in one + headless Godot over the synthetic data root (--synthetic-data, written over an empty data root) + and judged from the harness's report. Exit 0 only when every selected suite passed and the + engine log held no error a headless run does not explain. + +.DESCRIPTION + What the engine job in .github/workflows/checks.yml runs, and what a contributor runs on Linux + or macOS to reproduce it. It does not build or import; the job's steps before it, and a local + run, do: + + dotnet build CSVM/CSVM.sln + --headless --path CSVM --import + ./RunCiSuites.ps1 -Godot + + The Godot process gets CSVM_DATA_ROOT at a fresh empty folder, so no suite reads an extraction, + this tree's own extracted/ included. It also gets --synthetic-data, so the harness writes the + invented tree of CSVM.Tests/fixtures/ records into .scratch/synthetic-data// and reads that + in place of the empty folder; -NoSyntheticData runs over the empty folder alone. It gets + XDG_DATA_HOME, XDG_CONFIG_HOME and XDG_CACHE_HOME at + fresh folders, so user:// starts empty on Linux (macOS Godot ignores them and keeps the user's + own). Everything lands in .scratch/ci-suites/, wiped at the start of each run: the process's + stdout and stderr, and a copy of the engine log the harness screened. The report is the + harness's own .scratch/test-report.json. + + The verdict is drawn from that report, not from the exit code, because the harness also exits + 1 on an engine error line its allowlist does not cover, and a headless process prints lines a + windowed one does not. analysis/headless-limits.json lists those patterns, and this script + applies them exactly as sandbox/LinuxRelease.ps1 does. The run fails on any of: + - Godot killed by the watchdog, exiting with anything but 0 or 1, or writing no fresh report + - a report for another selector, data root or port base than this run's, or whose + syntheticData flag disagrees with the switch this run passed + - a FAILed suite; under tier:ci a listed suite that SKIPs is already a FAIL in the report + - an engine log the harness did not screen, or an allowlisted pattern over its cap + - an unexpected engine error line that no headless pattern matches + +.PARAMETER Godot + The Godot 4.7 .NET executable (the mono build), e.g. + Godot_v4.7-stable_mono_linux_x86_64/Godot_v4.7-stable_mono_linux.x86_64. + +.PARAMETER Selector + The --run-tests selector. Default tier:ci, the list CI requires to pass. + +.PARAMETER NetPortBase + The first port the suites open sockets from. Default 30000, below Linux's ephemeral range and + clear of the shipped game's 47500/47501. + +.PARAMETER TimeoutSec + Kill the run after this many seconds and fail it. Default 900. + +.PARAMETER NoSyntheticData + Run over the empty data root alone, without --synthetic-data. The ci tier fails that way, since + its plane suites then skip; it is for reading what the rest of a selection does with no data. + +.EXAMPLE + ./RunCiSuites.ps1 -Godot ~/godot/Godot_v4.7-stable_mono_linux_x86_64/Godot_v4.7-stable_mono_linux.x86_64 + +.EXAMPLE + ./RunCiSuites.ps1 -Godot $godot -Selector suite:menu-zone-layout +#> +#Requires -Version 7.0 +param( + [Parameter(Mandatory = $true)][string]$Godot, + [string]$Selector = "tier:ci", + [int]$NetPortBase = 30000, + [int]$TimeoutSec = 900, + [switch]$NoSyntheticData +) + +$ErrorActionPreference = "Stop" +$Inv = [Globalization.CultureInfo]::InvariantCulture +$Utf8 = New-Object System.Text.UTF8Encoding($false) +$RepoRoot = $PSScriptRoot +$Scratch = Join-Path $RepoRoot ".scratch" +$OutDir = Join-Path $Scratch "ci-suites" +$DataRoot = Join-Path $OutDir "empty-data-root" +$Report = Join-Path $Scratch "test-report.json" + +if (-not (Test-Path -LiteralPath $Godot -PathType Leaf)) { + throw "No Godot executable at $Godot -- pass the Godot 4.7 .NET (mono) binary as -Godot." +} +$Godot = (Resolve-Path -LiteralPath $Godot).Path + +# Godot's Linux build loads fontconfig for system fonts and logs an engine error on every lookup +# without it, which would read as unexpected errors; sandbox/LinuxRelease.ps1 refuses the same way. +if ($IsLinux) { + $ldconfig = if (Test-Path /sbin/ldconfig) { "/sbin/ldconfig" } else { "ldconfig" } + if (-not (@(& $ldconfig -p) | Where-Object { $_ -match 'libfontconfig\.so\.1' })) { + throw "No libfontconfig.so.1 on this system -- install it (Debian and Ubuntu: 'sudo apt-get install libfontconfig1')." + } +} + +$limits = [IO.File]::ReadAllText((Join-Path $RepoRoot "analysis/headless-limits.json"), $Utf8) | ConvertFrom-Json +$HeadlessEngineErrors = [ordered]@{} +foreach ($p in $limits.headlessEngineErrors.PSObject.Properties) { $HeadlessEngineErrors[$p.Name] = [string]$p.Value } + +if (Test-Path -LiteralPath $OutDir) { Remove-Item -LiteralPath $OutDir -Recurse -Force } +$xdg = Join-Path $OutDir "xdg" +foreach ($dir in @($DataRoot, "$xdg/data", "$xdg/config", "$xdg/cache")) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} +# A report left by an earlier run would otherwise be judged as this one's. +if (Test-Path -LiteralPath $Report) { Remove-Item -LiteralPath $Report -Force } + +$psi = [Diagnostics.ProcessStartInfo]::new($Godot) +foreach ($a in @("--headless", "--path", (Join-Path $RepoRoot "CSVM"), "res://scenes/Main.tscn", "--", + "--run-tests=$Selector", "--net-port-base=$NetPortBase")) { + $psi.ArgumentList.Add($a) +} +if (-not $NoSyntheticData) { $psi.ArgumentList.Add("--synthetic-data") } +$psi.WorkingDirectory = $RepoRoot +$psi.UseShellExecute = $false +$psi.RedirectStandardOutput = $true +$psi.RedirectStandardError = $true +$psi.Environment["CSVM_DATA_ROOT"] = $DataRoot +$psi.Environment["XDG_DATA_HOME"] = "$xdg/data" +$psi.Environment["XDG_CONFIG_HOME"] = "$xdg/config" +$psi.Environment["XDG_CACHE_HOME"] = "$xdg/cache" + +Write-Host "== engine suites, headless: --run-tests=$Selector ==" +Write-Host " godot $Godot" +Write-Host " data root $DataRoot (empty)$(if ($NoSyntheticData) { '' } else { ', replaced by --synthetic-data' })" +Write-Host " output $OutDir" +$watch = [Diagnostics.Stopwatch]::StartNew() +$proc = [Diagnostics.Process]::Start($psi) +$stdout = $proc.StandardOutput.ReadToEndAsync() +$stderr = $proc.StandardError.ReadToEndAsync() +if ($proc.WaitForExit($TimeoutSec * 1000)) { + $proc.WaitForExit() + $exit = $proc.ExitCode +} else { + $proc.Kill($true) + $proc.WaitForExit() + $exit = 124 +} +$seconds = $watch.Elapsed.TotalSeconds +[IO.File]::WriteAllText((Join-Path $OutDir "godot.out"), $stdout.GetAwaiter().GetResult(), $Utf8) +[IO.File]::WriteAllText((Join-Path $OutDir "godot.err"), $stderr.GetAwaiter().GetResult(), $Utf8) + +function Get-FullPath([string]$Path) { + return [IO.Path]::GetFullPath($Path).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) +} + +$problems = New-Object System.Collections.ArrayList +$rows = @() +$headlessSeen = [ordered]@{} +if ($exit -eq 124) { + $null = $problems.Add("Godot ran past the ${TimeoutSec}s watchdog and was killed; see godot.out") +} +if (-not (Test-Path -LiteralPath $Report)) { + $missed = @([IO.File]::ReadAllLines((Join-Path $OutDir "godot.out")) | Where-Object { $_ -match 'selector matched nothing|matched no suite' }) + $why = if ($missed.Count -gt 0) { ": $($missed[0].Trim())" } else { "; see godot.out and godot.err" } + $null = $problems.Add("Godot exited $exit and wrote no report$why") +} else { + $json = [IO.File]::ReadAllText($Report, $Utf8) | ConvertFrom-Json + if ($exit -ne 0 -and $exit -ne 1 -and $exit -ne 124) { + $null = $problems.Add("Godot exited $exit after writing its report, a crash in its teardown; see godot.err") + } + if ($json.selector -ne $Selector) { + $null = $problems.Add("the report is for selector '$($json.selector)', not '$Selector'") + } + # With the switch the harness reads the tree it wrote for its own process id, never the empty + # folder, so that tree is the one root a fresh, synthetic report may name. + $wantRoot = if ($NoSyntheticData) { $DataRoot } else { Join-Path $Scratch "synthetic-data/$($proc.Id)" } + $wantWhat = if ($NoSyntheticData) { "the empty" } else { "this run's synthetic" } + if (-not $json.dataRoot -or (Get-FullPath $json.dataRoot) -ne (Get-FullPath $wantRoot)) { + $null = $problems.Add("the report read data root '$($json.dataRoot)', not $wantWhat $wantRoot") + } + if ([bool]$json.syntheticData -ne (-not $NoSyntheticData)) { + $null = $problems.Add("the report's syntheticData is '$($json.syntheticData)', but this run $(if ($NoSyntheticData) { 'did not pass' } else { 'passed' }) --synthetic-data") + } + if ($null -eq $json.shard.netPortBase -or [int]$json.shard.netPortBase -ne $NetPortBase) { + $null = $problems.Add("the report opened its sockets from net port base '$($json.shard.netPortBase)', not $NetPortBase") + } + $rows = @($json.suites) + if ($rows.Count -eq 0) { + $null = $problems.Add("the report holds no suite") + } + $logFile = $json.engineErrors.logFile + if ($logFile -and (Test-Path -LiteralPath $logFile)) { + Copy-Item -LiteralPath $logFile -Destination (Join-Path $OutDir "godot.log") + } + if (-not $json.engineErrors.screened) { + $null = $problems.Add("the harness did not screen the engine log ($logFile), so its error lines went unchecked") + } + foreach ($a in @($json.engineErrors.allowlist)) { + if ([int]$a.seen -gt [int]$a.max) { + $null = $problems.Add("allowlisted engine error over its cap: $($a.pattern) seen $($a.seen)x, allowed $($a.max)x, $($a.why)") + } + } + $unexpected = [ordered]@{} + foreach ($u in @($json.engineErrors.unexpected)) { + $line = $u -replace '\s+', ' ' + $headless = $HeadlessEngineErrors.Keys | Where-Object { $line -match $_ } | Select-Object -First 1 + if ($headless) { $headlessSeen[$headless] = [int]$headlessSeen[$headless] + 1 } + else { $unexpected[$line] = [int]$unexpected[$line] + 1 } + } + foreach ($line in $unexpected.Keys) { + $null = $problems.Add("unexpected engine error ($($unexpected[$line])x): $line") + } +} + +$pass = 0; $fail = 0; $skip = 0 +foreach ($suite in ($rows | Sort-Object { [int]$_.index })) { + if ($suite.status -eq "pass") { $pass++; continue } + if ($suite.status -eq "skip") { + $skip++ + Write-Host (" SKIP {0} {1}" -f $suite.name, $suite.detail) -ForegroundColor Yellow + continue + } + $fail++ + Write-Host (" FAIL {0}" -f $suite.name) -ForegroundColor Red + foreach ($f in @($suite.failures)) { Write-Host " !! $f" -ForegroundColor Red } +} +foreach ($p in $problems) { Write-Host " !! $p" -ForegroundColor Red } +foreach ($pattern in $HeadlessEngineErrors.Keys) { + Write-Host (" allowed headless engine error /{0}/ seen {1}x: {2}" -f $pattern, [int]$headlessSeen[$pattern], $HeadlessEngineErrors[$pattern]) -ForegroundColor DarkGray +} +Write-Host (" {0} passed, {1} failed, {2} skipped, exit {3}, {4}s" -f $pass, $fail, $skip, $exit, $seconds.ToString("0", $Inv)) +if ($fail -gt 0 -or $problems.Count -gt 0) { + Write-Host "CI SUITES FAILED" -ForegroundColor Red + exit 1 +} +Write-Host "CI SUITES PASSED" -ForegroundColor Green +exit 0 diff --git a/analysis/README.md b/analysis/README.md index 7c1e28623..bd337fc69 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -32,3 +32,4 @@ importantly — the instrument bugs hit on the way, labelled measured / inferred | `bl-229-emitter-host-deactivation/` | May an `OBJECT_ACTIVE_STATE … INACTIVE` stop a puffer emitter that started in the same instant? | No — and it must still stop every other one. The 414 activate/emit/deactivate pairs split 32 same-instant (4 shapes, the splash family, every one authoring a 0.6 s run) against 382 a median 3.5 s later (`BL-224`'s population), with the smallest later gap at 1 ms. See `FINDINGS.md`. | | `campaign-coop-4p-perf/` | What is the frame cost and hitch behaviour of four panes over the heaviest shipped campaign mission (PLAN-campaign-coop D33)? | `CM18` (C4/M03) at 1P/4P x external/cockpit: draws grew 5.7x from 1P to 4P because the root viewport drew the world behind the panes; with that gone they grow 4.0x, the pane count, and the cockpit subtree is a fixed ~175 draws per pilot in a 16:9 pane. The mission's one dominant frame-time event, a ~300 ms `ai_spawn` hitch at two fixed sim frames, is identical at 1P and 4P, so it is not a splitscreen cost (filed as `BL-641`). See `FINDINGS.md`. | | `net-real-link/` | What do the guest clock slew, the match-state tick and the remote aeroplane's position error read over a real two-machine link? | On the house Wi-Fi (17 ms round trip, almost no loss): no snap, corrections under 62 ms, ticks a second apart within 52 ms, and a windowed position error of 0.3 to 0.4 m mean and 0.8 to 1.1 m worst, the same as two processes on one PC, so the floor is the machines' step timing. The method and its floor are in `analyze.py`'s header. | +| `ci-suite-survey/` | Which of the 495 in-engine suites can run on CI with no extraction, and what unblocks the rest? | 56 read nothing from the install, 3 use a chapter only as ground, 83 need only a plane, 87 need a few inventable records, and 266 check the shipped data and stay local. Inferred from each suite's gates and assertions, not from a run. See `FINDINGS.md`. | diff --git a/analysis/ci-suite-survey/FINDINGS.md b/analysis/ci-suite-survey/FINDINGS.md new file mode 100644 index 000000000..15d951fac --- /dev/null +++ b/analysis/ci-suite-survey/FINDINGS.md @@ -0,0 +1,35 @@ +# Which in-engine suites can run without an extraction + +**Question.** Of the 495 `[Suite]` bodies in `CSVM/src/Testing/`, which could run on CI, where no +install is present, and what would unblock the rest? The ci tier (`SuiteCatalog.CiTier`) and the `--synthetic-data` tree are built on the answer. + +**Instrument.** `suites.tsv`, one row per suite: file, suite name, bucket, and a one-line reason +naming the install input the suite needs or what it uses a chapter world for. Classified by reading +each suite's opening lines, its `RequireData`/`WithWorld` calls, the helpers it reaches and the +quantities it asserts. The largest files (`AiSuites`, `CombatSuites`, `CampaignSuites`, the Menu +and Original-shell suites) were classified from those signals rather than read line by line, so +each row is **inferred**, and a bucket count is good to about ten. + +**Buckets.** + +| Bucket | Count | Meaning | +|---|---|---| +| A | 56 | Reads nothing from the install. Many still SKIP behind a `RequireData(ctx.ZrdrPath)` the body never uses. | +| B | 3 | Uses a chapter world only as ground or a scene host: `ground-contact`, `forward-rotation`, `launch-direction-cache`. | +| C | 83 | Needs a plane and its records (planes gamez, plane stats, weapons, shakes, messages, a texture archive, sometimes sounds) and no chapter world. | +| D | 87 | Needs a few small authored records that can be invented: Original-shell layout screens, a multiplayer spawn table, anim and effect defs, a weather block. | +| E | 266 | Checks the shipped data or a named chapter, mission or node. Install-bound by construction. | + +**Measured.** The harness boots with no install: nothing between `_Ready` and the `--run-tests` +dispatch reads `extracted/`, `RequireData` ends a suite as SKIP, and `BuildWorld` requires the +chapter gamez and textures before building, so every world suite skips cleanly. + +**Inferred, to confirm by running.** `MusicSuites.cs` loads `ctx.ZrdrPath` and `ctx.SoundsPath` +with no `RequireData`, and `NetTeamSuites.cs` evaluates `MatchScores.Load(ctx.ZrdrPath)` as an +argument before `MatchSpec` reaches its own gates, so both should FAIL rather than SKIP without an +install. + +**Instrument caveats.** The reason column of a C or D row names the inputs as the classifier saw +them, not as a run proved them. Several C rows assert values of named real records (weapon ids and +speeds, damage ladders); the stand-in either reproduces those values or the assertion is retargeted, +and the row does not say which. diff --git a/analysis/ci-suite-survey/suites.tsv b/analysis/ci-suite-survey/suites.tsv new file mode 100644 index 000000000..3497c03d5 --- /dev/null +++ b/analysis/ci-suite-survey/suites.tsv @@ -0,0 +1,496 @@ +file suite bucket reason +AceWakeTerrainSuites.cs ace-wake-terrain E C2/M01 built terrain, hkfirebrand_9 roster pose, g35052 SHOW_BACKFACE polygons +AiAirframePoolSuites.cs ai-airframe-pool A counting logic with a stand-in builder lambda; no install input +AiAirframePoolSuites.cs ai-airframe-pool-claim D C4/M03 roster block + wave generator (egen) orders on a built world used as host, plane; node-for-node build comparison is generic but needs an authored roster + generator record +AiSuites.cs ai-actor C plane stats (nose part) + any gun weapon def + planes gamez + C1 textures; AI rig in a running sim +AiSuites.cs ai-engine-listeners C plane + sound defs + soundsh archive + C1 textures; nearest-human engine/weapon voice culling +AiSuites.cs ai-engine-rearm C plane + weapon/shake/sound defs + soundsh archive (engine loops) + C1 textures; two AI spawns on one airframe def +AiSuites.cs ai-far-field-plant C planes gamez + PlaneStats + C1 textures; AI rigs at set ranges +AiSuites.cs ai-gunnery C needs planes gamez + player stats + weapon defs + C1 texture archive (ProjectilePool); no chapter world plus AiSkills +AiSuites.cs ai-modes C needs planes gamez + player stats + weapon defs + C1 texture archive (ProjectilePool); no chapter world plus AiSkills and Maneuvers records (a maneuvers fixture already exists) +AiSuites.cs ai-net-follow E real C1 chapter net resolves by id/name and is followed (half uses EmptyStage) +AiSuites.cs ai-voice D plane + voice.zrd accent table + snd_id2_* sound set + soundsh archive; trigger dispatch/cooldown logic is generic but names VO id 2 clips +AiSuites.cs ai-weapon-emitters C plane + sound defs + soundsh archive + C1 textures; per-aircraft positional weapon emitters +AiSuites.cs c1-aa-guns E CM07 C1/M02 five aaguns, OBJECTIVE1 wake, persist chain, cm_sequence +AiSuites.cs carried-turrets C plane + weapon defs + TurretDefs/vehicle thirdp mount records + C1 textures; no world +AiSuites.cs empty-stage-net C EmptyStage ring net + plane + weapons + AiSkills + C1 textures; one check reads C1 nets only to prove no name collision (droppable) +AiSuites.cs flight-roster-transaction C FlightRoster atomic assembly: planes gamez, PlaneStats, WeaponDefs, Shake defs, Messages, C1 textures and C1 zrdr path; no world +AiSuites.cs inert-aircraft C planes gamez + PlaneStats + any gun WeaponDef + C1 textures; inert/activate on a live rig +AiSuites.cs mission-off-turrets E C3/M03 six switched-off balloon turrets vs C3/M02 six standing +AiSuites.cs turret-self-fire E C1's five named aagun emplacements and fort geometry +AiSuites.cs voice-runtime E voice.zrd 35 accent rows, accent 12 to VO id 2, real clip names, prewarm cost over real archive +AiSuites.cs world-turrets E real C1 world aagun NODES census pinned, zeppelin 14 rings +AiVesselTargetSuites.cs ai-vessel-targets E C1B/M03 four patrol boats, authored rating_biases of named pilots +AiWaveLaunchHitchSuites.cs ai-wave-launch-hitch E perf on real C4/M03 world, five cargozep1 launches +AirframeColliderSuites.cs airframe-collider-hit-rate E measures hit rate on every real player airframe hull vs mesh +AirframeColliderSuites.cs airframe-hull-coverage E every real player airframe hull vs its mesh, part names order +AirframeSwapSuites.cs campaign-airframe-swap E CM02 built world, decoded swap codes, shipped stat rows +AirframeSwapSuites.cs campaign-capture-silences-guns E CM02 Balmoral capture over its built world +AirframeSwapSuites.cs campaign-hangar-handover E CM07 hdrop_direction nodes and Blue Streak special-plane template +AirframeSwapSuites.cs campaign-warhawk-capture-livery E CM19 Warhawk capture and bhatwarhawk scheme over built world +AlphaCutoutRaySuites.cs alpha-cutout-ray-census E C3/M01 hydrogentank1 ray census, g482 plate, named nodes +AnimationAndEffectsSuites.cs anim-activation-prerequisite E C4/M03 cargozep1 five named restraint anims +AnimationAndEffectsSuites.cs anim-call-start-order A hand-authored AnimProgram on a code-built stage Node3D under ctx.Host; no chapter +AnimationAndEffectsSuites.cs barracuda-drive E C3/M03 barracuda node, sub_movement 1680 m, authored positions +AnimationAndEffectsSuites.cs bounce-launch E refuel1 authored speed/elevation bands, C1 only chapter shipping refuel* +AnimationAndEffectsSuites.cs effect-template-mesh D needs he_ground_effect def plus its he_ring templates on an effect stage; mesh-visibility semantics generic +AnimationAndEffectsSuites.cs effects-census E full --effects-test sweep of every shipped effect +AnimationAndEffectsSuites.cs emitter-host-deactivation D needs authored defs plane_big_splash / splasher puffer on a flat stage; BL-229 semantics generic +AnimationAndEffectsSuites.cs fbfx-flash E he_ground_effect authored six-step colour/run-time chain +AnimationAndEffectsSuites.cs forward-rotation B WithWorld(ctx.Chapter) used only as AnimRuntime/scene host; launches are hand-authored AnimData dicts on a probe node +AnimationAndEffectsSuites.cs ground-contact B WithWorld(ctx.Chapter, collision) used only as a collidable ground; bodies are hand-authored AnimData dropped on a downward-probed surface, no named node or count +AnimationAndEffectsSuites.cs launch-direction-cache B WithWorld(ctx.Chapter) used only as AnimRuntime/scene host; launches are hand-authored AnimData dicts on a probe node +AnimationAndEffectsSuites.cs nulled-launch E zep_ng_dstry1.flt authored eight-part band numbers +AnimationAndEffectsSuites.cs ordnance-burst-timeline E HE/flash/sonic timelines pinned to authored JSON +AnimationAndEffectsSuites.cs self-ref-launch E C5 agyrobus destructible and its fly script +AnimationAndEffectsSuites.cs wait-for-completion D needs authored anim defs (player_crash_water and callees) on a flat stage; WaitForCompletion semantics are generic (demo_anims fixture exists) +AudioBusSuites.cs audio-buses C plane stats + weapons + sound defs + soundsh archive + C1 textures + menu sounds dir; structural bus-placement walk of the scene tree +AudioLaunchSuites.cs audio-levels-launch A OptionsStore under ctx.ScratchDir and AudioServer buses; no install input +BalmoralCaptureSuites.cs campaign-balmoral-hidden E C3/M01 built world, balmoral node undrawn +BalmoralCaptureSuites.cs campaign-cm15-capture E CM15 C2/M05 built world, balmoral drop onto cargozep2 +BalmoralCaptureSuites.cs campaign-cm15-staged-paint E C2/M05 balmoral_1 roster livery and staged subtree +BindingLaunchSuites.cs bindings-launch-load A BindingStore files under ctx.ScratchDir plus a bare FlightController; no plane built +BuildStampSuites.cs build-stamp-focus A code-built NoGameDataScreen and BuildStamp over a scratch dir, Godot focus walk +BuildStampSuites.cs build-stamp-icons A BuildStamp UI built from ctx.RepoRoot with a stub opener +CampaignBalloonDeathSuites.cs campaign-balloon-death E C1/M05 nine lifesaver sites, 60/40 HP pools +CampaignBlackeDropSuites.cs blacke-drop-cameras E C4/M03 Blacke drop, two camera defs, marker states +CampaignBlackeSearchSuites.cs blacke-intro-handoff E C4/M02 generic_intro vs aiv.zrd 'player' spawn +CampaignBlackeSearchSuites.cs campaign-blacke-search E C4/M02 OBJECTIVE23 four waypoints, bhatgyro_1 +CampaignBlackeSearchSuites.cs cutscene-handoff-speed C planes gamez + PlaneStats + chapter textures; code-built marker, StageAt hand-back speed; no world +CampaignBlackeSearchSuites.cs net-blacke-warp E C4/M02 WARP_VEHICLE over lossy loopback +CampaignBriefingNoteSuites.cs campaign-briefing-note E all 24 missions briefing notes vs 240 px parchment +CampaignBriefingRepaintSuites.cs campaign-briefing-repaint D real LaunchMenu on cm_sequence/briefing records and menu layout; frame-cost/repaint logic generic +CampaignCabbieSuites.cs campaign-cabbie-run E C5/M01 Cabbie roster, taxi path pp1, nodes 2/11/4 +CampaignDedgVolumeSuites.cs campaign-dedg-volume E C3/M05 DEDG groups 1/5, 9000 m radius +CampaignDockingSuites.cs campaign-cm09-docking E C1/M04 CM09 piratezep docking chain, objective numbers +CampaignHudSuites.cs campaign-objectives-hud E first mission of ctx.Chapter objective graph + extracted mark art +CampaignHudSuites.cs mission-radio E first story mission's authored callout vocabulary +CampaignHumanFieldSuites.cs campaign-coop-human-field E C3/M01 OBJECTIVE5 and dzpath1 gates +CampaignLaunchHookSuites.cs campaign-launch-hook E C4/M04 six bhatwarhawk blocks, OBJECTIVE67, callback 801 +CampaignLaunchHookSuites.cs campaign-launch-hook-prop E C4/M04 anim2_warhawk and bmhookpoint nodes +CampaignLayoutSuites.cs campaign-layout-parity E decoded menu layout vs hardcoded chrome with six measured pinned rows +CampaignLoopSuites.cs campaign-loop E whole campaign loop on mission 1, real profile, briefing, cutscene +CampaignMarkerSuites.cs campaign-balloon-marker E C1/M05 nine lifesaver sites markers, OBJECTIVE10 +CampaignMarkerSuites.cs campaign-objective-labels E C1C/M01 targets table labels and names +CampaignMarkerSuites.cs campaign-objective-markers E first story mission targets.zrd sites +CampaignMarkerSuites.cs campaign-objective-target-path E C1/M04 piratezep/rock_zeppelin path target +CampaignMilitiaLiverySuites.cs campaign-militia-livery E CM01/CM02/CM16/CM19 militia defs and patterns +CampaignPandoraLossSuites.cs campaign-pandora-loss E C3/M04 twelve nacelles, 40 HP pools, OBJECTIVE10 45 s +CampaignPlaneChangeSuites.cs campaign-flight-check-plane-change E FLIGHTCHECK.SCRIPT rules and authored reward row, cm_sequence wingman rows +CampaignPlayerDeathSuites.cs campaign-player-death E C3/M01 mission authoring no loss +CampaignRaceSuites.cs campaign-race-chain E C2/M03 Hollywood race OBJECTIVE17-19, dzpath1-3 +CampaignRacerSuites.cs campaign-racers E C2/M03 six hafury racers, seven tagged zones +CampaignRosterObjectiveMarkerSuites.cs campaign-cm02-roster-marker-relabel E C3/M05 three Balmorals and SET_HELP_LABEL objectives +CampaignRosterObjectiveMarkerSuites.cs campaign-cm15-roster-objective-marker E C2/M05 balmoral_1 flag, OBJECTIVE21 +CampaignRosterObjectiveMarkerSuites.cs campaign-cm21-cabbie-marker E C5/M01 autogyro_1, OBJECTIVE28 +CampaignRosterSuites.cs ai-voice-mission E C1/M02 roster blakepeace_2_1 accent -1, wingman voices +CampaignRosterSuites.cs campaign-bomber-crash-probe E C3/M05 bombers in a collision world +CampaignRosterSuites.cs campaign-bomber-formation E C3/M05 three netted bombers, net 19 +CampaignRosterSuites.cs campaign-kill-credit E C3/M05 roster britbalmoral/britpeace tallies +CampaignRosterSuites.cs campaign-roster E C1/M04 shipped roster census (who gets rigs, escorts, nets) +CampaignRosterSuites.cs roster-skill-difficulty E C1/M02 authored blakepeace_2_1 / bloodhawk_2 skill numbers +CampaignRosterSuites.cs roster-spawn-names E C1/M02 roster names wingman_4, bloodhawk_2 +CampaignRosterSuites.cs roster-voice-ratings E C5/M01 autogyro_1 authored talker/constitution (7, 8) +CampaignScratchProfileSuites.cs campaign-scratch-profiles D CampaignSequence + C3/M01 mission zrdr + profile store; world only hosts a crash that produces a Lost outcome; assertions are about profile files +CampaignSetAiSuites.cs campaign-set-ai-net E C3/M05 M5Bombers/M5Bombrun/M5Escort nets, OBJECTIVE4/68 +CampaignSnapshotSuites.cs campaign-danger-zone-snapshot E C3/M01 dzpath1, Snap_1_18 row, 640x480 photo +CampaignSquadWakeSuites.cs campaign-roster-wake-node E C3/M05 britpeace_7 FindNodes override, OBJECTIVE5/8 +CampaignSquadWakeSuites.cs campaign-squad-wakeup E C3/M05 britpeace_7/8/9, OBJECTIVE5/8 +CampaignStuntMarkerSuites.cs campaign-cm11-stunt-marker E C2/M02 secfury_5/6 flags +CampaignSuites.cs campaign-coop-attempt E mission reward table answer for the real mission and mask +CampaignSuites.cs campaign-cutscene E C1/M04 intro callback codes and C3/M03 opening scene +CampaignSuites.cs campaign-danger-zones E C3/M01 dzpath1/dzpath4 gates +CampaignSuites.cs campaign-mission-cash E C3/M01 $900 on bit 12, OBJECTIVE30 +CampaignSuites.cs campaign-mission-end E real mission INACTIVEn kill, destruction log, docking film codes +CampaignSuites.cs campaign-objectives E a shipped mission's BEGIN_DORMANT timings, INACTIVEn, INSTANTWIN +CampaignSuites.cs campaign-persistence E three real PERSIST_LOG objects of a campaign mission across profile store +CampaignSuites.cs cutscene-letterbox E chapter letterbox node as authored, pane ratios +CampaignSuites.cs persist-chain-kill E C3/M01 fuel trucks to bridge chain, C3/M05 deck +CampaignSupportBeamSuites.cs campaign-support-beam-destroyed E C5/M01 rfspt4 spprt node +CampaignTargetListSuites.cs target-mission-list E all 24 missions target tables; CM01/CM06/CM07 cycle contents +CampaignTravelersDeleteSuites.cs campaign-cabbie-delivered E C5/M01 OBJECTIVE20 TRAVELERS point and Cabbie +CampaignTravelersDeleteSuites.cs campaign-travelers-delete-forms E C5/M02 OBJECTIVE29, C2/M05 OBJECTIVE35 +CampaignWakeCarrySuites.cs campaign-wake-trailer-carry E C1/M04 blakebloodhawk_8 M4ReinfAce +CampaignZeppelinCommandSuites.cs campaign-set-ai-zeppelin-net E C4/M05 blackhatzep M5Hatzep/M5ZepAttack +CampaignZeppelinCommandSuites.cs campaign-set-ai-zeppelin-team E C2/M05 cargozep2 OBJECTIVE36 +CampaignZeppelinCrashSuites.cs campaign-zeppelin-crash E C3/M01 cargo zeppelin tanks, gasbag burns, cargozep1_crash +CampaignZeppelinSuites.cs campaign-zeppelins E C3/M01 zeppelins.zrd and egen.zrd authored empty +CampaignZeppelinWakeSuites.cs campaign-zeppelin-wakeup E C3/M01 cargozep1 team and OBJECTIVE39 +CaptureChuteSuites.cs campaign-capture-chutes E C3/M05 CM02 capture wing walk chute defs +CaptureGroupSuites.cs campaign-capture-group E C3/M05 CM02 capture group counts, DEDG, britbalmoral_1 +ChaseTrailSuites.cs chase-trail A CameraController over a bare Camera3D and scripted attitudes; no plane or world +CinemaSkipSuites.cs cinema-skip-pad A synthetic InputEvents through CinemaSkips.PressOf; pure logic +CloudBandObjectGateSuites.cs cloud-band-object-gate E C1C/M01 zeppelin hull vs CLOUD_COVER band +CloudFieldSuites.cs cloud-field-fade E per-chapter pinned cloud sprite counts C1/C1C/C5 +CloudFieldSuites.cs cloud-puffs E per-chapter C1/C5 fvol masks and tints +CloudFieldSuites.cs fogvol-banks E per-chapter authored fvol volume counts and whiteout flag +ClutterActivationSuites.cs clutter-activation E CM01 island hidden node stamps +ClutterActivationSuites.cs clutter-cells D cuts the largest clutter kind of the chapter into cells; invariants are structural over a placements list a hand-authored KindExport could supply +ClutterCardDepthSuites.cs clutter-card-depth E C1 firtree card rendered at three ranges, pixel counts +CombatSuites.cs ai-plane-defs E all eleven real airframes seed damage defs +CombatSuites.cs ai-spawn-jitter C planes gamez + PlaneStats + weapons + shake defs + C1 textures; real spawner, determinism and spread +CombatSuites.cs aim-assist E asserts shipped sticky_bullet_* player.json values and dist_factor 0.0 (logic half is C, splittable) +CombatSuites.cs air-to-air C planes gamez + PlaneStats + weapon defs + C1 textures; rounds, rockets, kills between built planes +CombatSuites.cs blast-neighbor-shape C one named weapon record (wep_14 torpedo HEALTH_DAMAGE, IMPACT_PROXIMITY) + C1 textures; code-built wall and neighbour bodies +CombatSuites.cs canopy-hole-draws E chapter crash def table bullet1..5 and overlay roots in the chapter gamez +CombatSuites.cs engine-cockpit-pitch C PlaneStats + sound defs + soundsh archive + weapons; cockpit loop pitch option +CombatSuites.cs engine-damage-phases C PlaneStats + sound defs + soundsh archive + weapons; own-ship engine slot timing +CombatSuites.cs engine-note E shipped player_bhawk curve 0.6-1 and CAP-10 measured 0.9370 +CombatSuites.cs engine-voice-duck C PlaneStats + sound defs + soundsh archive + weapons; MissionRadio duck law +CombatSuites.cs graze-bounce C planes gamez + PlaneStats (bounce_factor 0.6 is a precondition a synthetic stat can carry) + C1 textures; code-built floor/wall +CombatSuites.cs incoming-fire-cues D plane + AiSkills + stock loadout + bullet_hit_sound and snd_bulletpass/snd_ricochet sound groups + soundsh archive; real AI gunnery +CombatSuites.cs loadout-bind E every stock loadout and marker on every real airframe +CombatSuites.cs loadout-forrig E every firepoint/pylon on all 11 real airframes +CombatSuites.cs muzzle-flash-cockpit-hidden C WeaponDefs + C1 textures for ProjectilePool; view mode switch +CombatSuites.cs muzzle-flash-rides-muzzle C WeaponDefs + C1 textures for ProjectilePool; code-built moving muzzle +CombatSuites.cs muzzle-light-first-person-point-term C any gun WeaponDef + C1 textures; the pair values are compiled constants in the pool +CombatSuites.cs ring-death-effects-follow-hull E C1C/M01 gun ring on moving hull +CombatSuites.cs team-model C planes gamez + PlaneStats + weapon defs + C1 textures; two teammates and an enemy +CombatSuites.cs trail-world-anchor D pufftrails.json firepuffer def (zrdr) + C1 textures; code-built rotated carrier +CombatSuites.cs turret-death-effect-world-anchor C WeaponDefs + TurretDefs records only; code-built carrier hull and site nodes +CombatSuites.cs turret-death-fire-follows-hull E C1/M04 piratezep doublecannon4 ring +CombatSuites.cs weapons-fire E census of all 48 shipped weapons +CoopCutsceneSuites.cs campaign-coop-cutscene-fullscreen A SplitScreen panes plus a CutsceneController over an empty AnimProgram and a code-built world node +CoopDeathSuites.cs campaign-coop-death E co-op loss rule driven over real C3/M01 objective script + built world + planes; generic rule but needs a whole mission (E-soft: invented mission zrdr w/o loss + plane + EmptyStage) +CoopDropoffSuites.cs campaign-coop-dropoff E C3/M01's authored drop-off definition and its 'player' re-placement driven with two humans (E-soft) +CoopEpisodeOwnerSuites.cs campaign-coop-episode-owner E CM02's own capture/swap definitions with two humans (E-soft) +CraneAimPointSuites.cs cm15-crane-aim-point E C2/M05 zcraneN/healthy marker positions on the real zeppelin +CraterSuites.cs crater-carve E C1 forested-middle ground scan, struck node ArrayMesh, clutter decorations inside radius, 'no C1 node carries can_modify', real wep_12 (E-soft: EmptyStage + invented clutter would cover the carve geometry) +CraterSuites.cs scorch-decals C weapon def wep_06 (rocket with crater radius) + wep CRATER/gun hit defs + a chapter texture archive for the projectile pool; tarmac/grass/water plates are built in code +CutsceneAiParkSuites.cs cutscene-ai-park-intro E CM14 intro (C2B/M04 generic_intro) code 20 and Gemini bay generator, real mission graph +CutsceneAiParkSuites.cs cutscene-ai-park-mission-start E C1/IA1 wave aircraft parked at mission start via real bootstrap definition (E-soft) +CutsceneFastForwardSuites.cs campaign-cutscene-fast-forward E CM02 built world, its authored codes/clock/pose path under a held input +CutsceneHostCodeSuites.cs campaign-wingman-removal E CALLBACK 968 over C4/M03 docking film and its aiv roster (bswingman_1) +CutsceneHostCodeSuites.cs cutscene-clear-ordnance E CALLBACK 86 raised by C1/M02 hangar drop, the one install definition that does +CutsceneOwnershipSuites.cs campaign-cutscene-ownership E C5/M02 objective chain and its authored ending definitions +CutsceneSkipSuites.cs campaign-cutscene-skip E CM02 capture definition closure authors no hold code; asserts the authored timing +DamageSuites.cs ai-damage-stages E spawns real Fury and Bloodhawk through FlightRoster over the chapter crash program; asserts random_remote_damage/pfsmoketrail counts and the Bloodhawk's missing elevator pair (shipped-data parity) +DamageSuites.cs cockpit-panel-staging C planes gamez tree with pdp4/pdp6 plus cockpit1 interior with pcdp4/pcdp6 + plane stats injure_anims pdpanel4/6 + texture archive +DamageSuites.cs damage-hd E Probes.Damage census of every destructible of the chapter (hits, reset, rekill, collider census) +DamageSuites.cs damage-stage-slots C zrdr plane stats only: player_fury AI ladder (7 injure entries, six random_remote_damage) and player_fury pdpanel names; the rest is a code-built synthetic ladder (fury counts are parity numbers a stand-in record would carry) +DamageSuites.cs damage-stages E Probes.Damage census over every DAMAGE_SEQUENCE def of the chapter +DamageSuites.cs damage-staging-pool C planes gamez node tree with pdpN torn-panel nodes + zrdr plane stats (destroyable_parts MaxArmor/MaxHp, injure_anims pdpanelN) + a texture archive; data-driven zone pick +DamageSuites.cs damage-template-freed-anchor D same as damage-template-pool: pdpanel4/5 + gimmeflakes anim defs and planeflakes gamez root +DamageSuites.cs damage-template-pool D chapter anim program subset pdpanel4/pdpanel5 + gimmeflakes defs and a 'planeflakes' gamez root, staged on a code-built rig (anim defs + one gamez node) +DanteEngineFireSuites.cs dante-engine-fires E C5/M04 Dante's engines, large_30sec_fire per engine, fire_here pool depth +DestroyChoreographySuites.cs ai-crash-defs E ai_crash_* family indexed by surface id (dirt 13 -> ai_crash_dirt, default), player_crash_* on the human rig +DestroyChoreographySuites.cs ai-crash-rig-deferral E BL-641 deferred AI crash rig through the roster pump over the chapter crash program +DestroyChoreographySuites.cs ai-wreck-fall E AI Fury fall: self-named destroy def, no ai_crash_*, Callback 15 at authored 3.0 s, surface-indexed crash def, BL-451 lever +DestroyChoreographySuites.cs callback-events E census of every destroy def's authored callback codes in the chapter, code 0 never authored +DestroyChoreographySuites.cs called-death-chain E C3/M01 bridge_truck01 kills susp_bridge via chainreaction EVENT_OFFSET +DestroyChoreographySuites.cs carried-pose-called-sequence E C3/M01 susp_bridge named spans rope1/part3a/dbase and its ON_CALL fire-puffer sequences +DestroyChoreographySuites.cs carried-state-silent D PERSIST_LOG destructible with healthy/destroyed pair, found by scan of the chapter (invented destructible def would do) +DestroyChoreographySuites.cs crash-rig-anchors E Devastator model root shares the crash defs' authored NAME; bhawk/pfighter pooled template reset pose +DestroyChoreographySuites.cs death-not-a-revival E C3/M03 Barracuda sub_destruction, a lifesaver, a constructed def: four named authored chains +DestroyChoreographySuites.cs death-respawn-rest-pose E bhawk and autogyro after death/respawn at 3 s and 15 s over the real player-player def +DestroyChoreographySuites.cs death-slot E chapter destructible with a calling destruction slot (the 30 s fire's 1,035 death calls) +DestroyChoreographySuites.cs debris-shading E C1 refuel debris authored at vertex colour 119 on a sheet of mean texel 63 vs tank at 254 +DestroyChoreographySuites.cs emitter-prewarm E bhawk crash rig PUFFER_STATE emitters over the shipped crash defs and sonic bursts +DestroyChoreographySuites.cs first-person-condition D one anim def (bullet1) with a PLAYER_1ST_PERSON IF/ELSE branch, run on a code-built stage +DestroyChoreographySuites.cs nitro-ai-edges E AI nitro: medium_aishake, snd_nitro 0.1 s blip, nitro_decay instance lockout (real defs and sound set) +DestroyChoreographySuites.cs nitro-boost-anchors E nitro_boost/nitro_decay authored NAME 'warhawk' anchor and nitropuff1 puffer +DestroyChoreographySuites.cs nitro-boost-flown-rig E Warhawk nitro on the WorldEffectsFactory rig, nitropuffN on the aircraft's exhaust nodes +DestroyChoreographySuites.cs player-destroy-choreography E player-player def over all eleven airframes: NODE_ACTIVE 1 is player_autogyro, four wreck pieces, Callback 3 +DestroyChoreographySuites.cs prop-slot-edges E Warhawk stopprops/spinprops cross-fade over the shipped defs, human and AI rigs +DestroyChoreographySuites.cs repeat-call-slots D pdpanel7 anim def with four gimmeflakes CALL_ANIMATION at pdp7 + planeflakes gamez root (invented def with N repeated calls; authored count 4 is the real shape) +DestroyChoreographySuites.cs start-state-swap-pool D a destructible whose Initial sequence authors the healthy/destroyed swap (scanned from the chapter; one invented destructible def + world nodes would do) +DestroyChoreographySuites.cs stop-sequence E real large_fireball (0.3 s stopper), large_30sec_fire halt, car_loop1_start/car_dust1 lap defs +DisplaySettingsSuites.cs display-det-guard A reflection over OptionsDef and the SavedWord(det) readers plus a MenuSuiteHost menu; no install read +DisplaySettingsSuites.cs display-mode D first half is pure setting-resolution logic (install-free); the VIDEO page leg needs the Original shell layout fixture (LAYOUT.CSV with the Options/VIDEO page widgets + invented art) via OriginalAvailability +DisplaySettingsSuites.cs display-monitor D first half is pure setting-resolution logic (install-free); the VIDEO page leg needs the Original shell layout fixture (LAYOUT.CSV with the Options/VIDEO page widgets + invented art) via OriginalAvailability +DisplaySettingsSuites.cs display-render-scale D first half is pure setting-resolution logic (install-free); the VIDEO page leg needs the Original shell layout fixture (LAYOUT.CSV with the Options/VIDEO page widgets + invented art) via OriginalAvailability +DisplaySettingsSuites.cs display-resolution D first half is pure setting-resolution logic (install-free); the VIDEO page leg needs the Original shell layout fixture (LAYOUT.CSV with the Options/VIDEO page widgets + invented art) via OriginalAvailability and the Built-in custom size row +DisplaySettingsSuites.cs display-vsync D first half is pure setting-resolution logic (install-free); the VIDEO page leg needs the Original shell layout fixture (LAYOUT.CSV with the Options/VIDEO page widgets + invented art) via OriginalAvailability +DropoffChutemanSuites.cs dropoff-chuteman-stage E C3/M01 chuteman/pilot/stamp cross-archive pointers of the compiled drop +DropoffPlacementSuites.cs cutscene-handoff-unposed E C2/M05 paratrooper drop definition, no 'player' node, handoff pose +DropoffPlacementSuites.cs dropoff-placement E C3/M01 cutscenes directory's re-placement definition and 'player' node pose +EnetTransportSuites.cs enet-dual-stack A IPv4+IPv6 loopback ENet; no install +EnetTransportSuites.cs enet-load-stall A loopback ENet service-thread stall; no install +EnetTransportSuites.cs enet-stable-ipv6-reply A real host IPv6 addresses; skips without stable+temporary global IPv6 (may SKIP on CI); no install +EnetTransportSuites.cs enet-transport A loopback ENet in-process; no install +ExhaustSmokeSuites.cs exhaust-smoke C planes gamez with exhaust markers + plane stats (throttle/exhaust data) + texture archive; no world +ExhaustSmokeSuites.cs exhaust-smoke-ai C same plane inputs plus an AI roster spawn (AiDef) and per-marker exhaust trails; no world +ExtractionScreenSuites.cs extraction-picker A Godot FileDialog over scratch folders +ExtractionScreenSuites.cs extraction-screen A the extraction UI over a scratch fake install built in code (docstring: reads or writes no real install) +FlightInputHandoffSuites.cs flight-input-handback A CutsceneController/FlightController input latches with a code-built rig and PauseState; no data read +FlightInputHandoffSuites.cs flight-live-respawn-gate C planes gamez + plane stats (fuel tank) + a texture archive; human rig, respawn with a pinned/unpinned tank +FlightTelemetrySuites.cs flight-telemetry-gate C three AI aircraft through FlightRoster: planes gamez + plane stats + weapon defs + shakes + texture archive; log sink line count +GeminiBreakupRestSuites.cs gemini-breakup-rest E C2B/M04 geminizep kill chain, five breakup sections resting on water +GeminiGasbagBaySuites.cs gemini-engines-leave-the-bays E C2B/M04 OBJECTIVE11-13 and the fourteen engines, authored defs +GeminiGasbagBaySuites.cs gemini-gasbag-bays E C2B/M04 Gemini gasbag/bay authored torpedo defs and all_gmzep_gasbags (BL-694) +GeneratorCreditSuites.cs generator-callback-credit E C4/M03 cargozep1 generator capacity 0 and callback 800 credit rule on the real mission +GeneratorLaunchCountSuites.cs generator-launch-danger-zone E C5/M04 dantezep launch, dzpath34 tagged node, dzones.zrd +GeneratorLaunchCountSuites.cs generator-launch-dedg E C5/M04 dantezep generator, stihellhound_5_eg0 launch, DEDG group counts, OBJECTIVE38/39 +GeneratorLaunchCountSuites.cs generator-sub-kill-disables-bay E C3/M03 barracuda submarine 'subhealthy' node and its generator bay +GeneratorRosterSuites.cs generator-roster-params E C5/M04 and C1/M04 roster census (stihellhound_5_7, Eairg32_params): shipped-data parity +GraphicsSwitchSuites.cs graphics-live-switch E whole flight session over the real chapter; relational (fresh vs switched) but content-bearing: needs clutter cells, clouds, crater stamps, merged-surface counts, a plane (E-soft: invented small chapter with fogvol clutter + weather clouds + stand-in plane) +GraphicsSwitchSuites.cs graphics-shader-twins E whole flight session over the real chapter; relational (fresh vs switched) but content-bearing: needs clutter cells, clouds, crater stamps, merged-surface counts, a plane (E-soft: invented small chapter with fogvol clutter + weather clouds + stand-in plane) +GraphicsSwitchSuites.cs graphics-switch-cover A SwitchCover/GameClock/PauseState timing driven in code; the shard-level RequireData is never called by this body +GraphicsSwitchSuites.cs graphics-view-distance E whole flight session over the real chapter; relational (fresh vs switched) but content-bearing: needs clutter cells, clouds, crater stamps, merged-surface counts, a plane (E-soft: invented small chapter with fogvol clutter + weather clouds + stand-in plane) +GraphicsSwitchSuites.cs world-merge E whole flight session over the real chapter; relational (fresh vs switched) but content-bearing: needs clutter cells, clouds, crater stamps, merged-surface counts, a plane (E-soft: invented small chapter with fogvol clutter + weather clouds + stand-in plane) +GroundShadowSuites.cs ground-shadow C mostly EmptyStage already; needs planes gamez + zrdr stats + a texture archive for the plane; one leg uses C1 terrain height via WithWorld (generic terrain, would move to a raised EmptyStage box) +GunVoiceReachSuites.cs gun-voice-reach E C4 belly ring snd_chaingun authored RANGE, snd_30cal, real sound archive: the point is what the shipped data buys +HudKillLineSuites.cs hud-kill-line C a spawned AI Kestrel (player_kestrel, AiDef medkestrel, title 'Medusa Kestrel') through FlightRoster: planes gamez + plane stats + weapon defs + texture archive + message table rows (was shot down / Wingman was shot down / was destroyed) +InstantActionNetSeatSuites.cs instant-action-wave-net-seat D IA mission zrdr (ia fixture exists) + AI nets (ainets fixture exists) + a roster-spawnable plane; asserts walk seating, generic over data (E-soft: 'C1/IA1's own data') +InstantActionSuites.cs instant-action C plane gamez + stats + weapon defs + AI skill rows for ace/wingman roster spawns on a code-built stage; the rest is pure logic over ia defs (ia fixture covers) +InstantActionSuites.cs instant-action-end E C1/IA1 authored stunt zones and C1/M04 piratezep engines, real signals +InstantActionSuites.cs instant-action-marker-names E C1/IA1 and C5/IA1 ace_name via real string table, wingmen Jack/Tex/Buck/Big John/Betty +InstantActionSuites.cs instant-action-stunt-summary D StuntMission.Load needs a gamez with Danger Zone nodes + an IA mission zrdr with stunt zones + messages.json; the ScoreStore logic is generic +InstantActionSuites.cs instant-action-voice E C1/IA1 accent table, 13 militias, clipless pilot id 5, streamed DE clips from the real sound archive +InstantActionSuites.cs instant-action-wrapup E first half (shot counters) needs only a cannon + rocket weapon def and a plane (C); second half flies a C1/IA1 stunt run for the photograph row +InstantActionSuites.cs instant-action-zeppelin E C1/IA1 real world: multiplayer1zep, gasbag collision shapes, cargobay drop point +InstantActionSuites.cs results-board-shell A ResultsBoard shell contract over ShellProbeBoard and PauseState, code only +IntroAircraftSuites.cs intro-aircraft-stage E first story mission's 'player'/'piratefighter' intro pointers, OBJECT_ADD_CHILD airship +LandingApproachSuites.cs campaign-coop-approach-row E first story mission auto row with two humans +LandingApproachSuites.cs campaign-coop-hookup-seat E first story mission auto row, two humans same airframe, extend-hook fork +LandingApproachSuites.cs landings-approach-trigger E first story mission landings.zrd rows, cone/half-cone/sphere volumes +LandingApproachSuites.cs landings-auto-land-button E first story mission auto row, message-table prompt +LandingApproachSuites.cs landings-balmoral-dock E CM02 ending over C3/M05, Balmoral wing-fold 2 s branch +LandingApproachSuites.cs landings-car-pickup-credit E CM16 armoured-car pickup, destroy_car01 beat +LandingApproachSuites.cs landings-docking-hold E CM06 docking onto the Workers' Voyage, the one shipped row raising no code +LandingApproachSuites.cs landings-hangar-drop-gate E CM07 zeppelin-hangar drop, mission-named definitions and objective node +LandingApproachSuites.cs landings-hookup-airframe E zeppelin hookup on three airframes over the first story mission (per-airframe branches of the shipped fork) +LandingApproachSuites.cs landings-trailer-pickup-gate E CM11 trailer pickup via objective WAKE_ANIM +LandingApproachSuites.cs landings-train-pickup-gate E CM07 caboose pickup, pickup_timing, real mission path +LandingApproachSuites.cs landings-train-pickup-ride E CM07 moving train, caboose child offset, flare smoke +LandingApproachSuites.cs landings-wingwalk-gate E CM02 Balmoral capture gate, roster, three approach rows +LoadProgressSuites.cs load-progress E extracted strip widths 236/338, C3/M01 sheet propeller cycle, six extracted frames: art parity +LoadSheetSuites.cs load-sheet E 24 Loading.zrd campaign dialogs, every named bitmap exists in the extraction: census +LookStickSuites.cs look-stick C planes gamez (chase camera + cockpit head) + plane stats + texture archive; flag parsing is pure +LookStickSuites.cs look-stick-edge C planes gamez + plane stats + camera over a flown rig; assertions are relative to the stick's own motion +MenuCampaignSuites.cs menu-campaign-journey D Built-in campaign journey over a scratch profile store; only the briefing legs (--menu=campaign-briefing:24 reveal clock, narration starts) need one cm_sequence entry + briefing dialog/objectives record (CampaignBriefing.Load); mission count/cinemas are code +MenuCaptureSuites.cs menu-screenshot-key A real key event through the viewport, screenshot folder count; no install data +MenuCoopDoorSuites.cs menu-coop-door A Built-in campaign network door over two launchscreens on loopback; scratch profile; briefing not needed +MenuHangarSuites.cs menu-hangar-journey A Built-in hangar journey, scratch plane store; stock loadouts live in res://, stock armour reads degrade to zeros without zrdr; verify armour page checks +MenuHangarSuites.cs menu-original-hangar D Original hangar over the shell: layout fixture needs the name screen, Plane Construction hub, tabs, dropdowns, description box, decal grid widgets + invented art + string table rows (wallet-free/over-wallet notes) +MenuHostSuites.cs menu-host-pointer A Built-in presentation under mouse events through MenuHost; code-built +MenuHostSuites.cs menu-host-tracer A Built-in Free Flight through MenuHost; code-built chapters and roster +MenuInstantActionSuites.cs menu-instant-action-journey A Built-in Instant Action screens; InstantActionFeature falls back to InstantAction.Defaults() without ia.zrd +MenuInstantActionSuites.cs menu-original-instant-action D Original Instant Action screen: layout fixture with the IA section + ia.zrd per environment (ia fixture exists) + stock loadouts (res://); ammo chrome +MenuJourneySuites.cs menu-free-flight-journey C Built-in menu is code-built (chapters, roster); only the 'Top Speed' detail check needs plane stats for the first airframe (player_autogyro) via PlaneStats; rest A +MenuLaunchReturnSuites.cs menu-backdrop D background on Options restart/launch: layout fixture main menu + Options (Built-in options 18 steppers are code) +MenuLaunchReturnSuites.cs menu-launch-return D launch/return through Original presentation; layout fixture main menu + Free Flight/IA screens +MenuNetPlaySuites.cs menu-host-address A Built-in host address screen over loopback +MenuNetPlaySuites.cs menu-net-door A Built-in multiplayer door over loopback +MenuOriginalCampaignSuites.cs menu-original-campaign D Original campaign through the shell: layout fixture campaign screens + briefing records (CampaignBriefing), memento art/ids (CampaignMementos), narration clip names, scratch profiles +MenuOriginalCheatSuites.cs menu-original-cheats D Original cheats: layout fixture cheat screen + briefing records for mission list +MenuOriginalConnectionSuites.cs lan-discovery A LAN discovery over loopback sockets; no RequireData +MenuOriginalConnectionSuites.cs menu-original-boot D Original boot card layout fixture +MenuOriginalConnectionSuites.cs menu-original-builtin-host D Original built-in host box layout fixture +MenuOriginalConnectionSuites.cs menu-original-connection D Original connection screen: layout fixture with the Connection box widgets +MenuOriginalConnectionSuites.cs menu-original-coop-boot D Original co-op boot through the shell: layout fixture campaign + net screens + stock loadouts (res://) + loopback; campaign profile scratch +MenuOriginalConnectionSuites.cs menu-original-ipv6-address D Original IPv6 address entry box layout fixture +MenuOriginalConnectionSuites.cs menu-original-lobby D Original lobby screen layout fixture +MenuOriginalConnectionSuites.cs menu-original-lobby-teams D Original lobby team widgets layout fixture +MenuOriginalConnectionSuites.cs menu-original-outlaw-list D Original outlaw list screen layout fixture +MenuOriginalConnectionSuites.cs menu-original-version D Original version-mismatch box layout fixture +MenuOriginalControlsSuites.cs menu-original-controls D Original Options CONTROLS/KEYS pages: layout fixture with MM_B_PREFERENCES, Options doors, CONTROLS and KEYS widgets (seven category strips, three column heads, authored button order) + invented art +MenuOriginalCoopFilmSuites.cs menu-original-coop-film D Original co-op film hand-off: layout fixture campaign cabin/coop door; films are recorders (ChapterCinema counts are code) +MenuOriginalCoopFlowSuites.cs menu-original-coop-flow D Original co-op flow: layout fixture campaign/lobby screens + briefing records; loopback transport +MenuOriginalSuites.cs menu-join-board D Original join board: layout fixture join screen + loopback/net-info boxes +MenuOriginalSuites.cs menu-original-tracer D Original presentation tracer: main-menu layout fixture (the fixture's main menu panes/buttons already exist) +MenuOriginalWrapupSuites.cs menu-original-wrapup D Original wrap-up board: layout fixture + messages.json + a gamez with Danger Zone nodes and an IA mission zrdr with stunt zones for the StuntMission photographs leg (C4/IA1 today) +MenuPlayerSetupSuites.cs menu-controls-seats A Built-in Controls screen join, scratch binding store +MenuPlayerSetupSuites.cs menu-player-setup-journey A shared player-setup feature through a Built-in launchscreen +MenuPlayerSetupSuites.cs menu-player-setup-seats D Built-in seats leg is A; Original leg needs layout fixture Dogfight/aircraft/ammo screens + ia def +MenuZoneSuites.cs menu-zone-layout A Built-in launchscreen band metrics over the paint aid; the ZrdrPath gate is incidental (hangar reads degrade to zeros) +MeshLightSuites.cs mesh-light-blink E C5's authored beacon blink periods 1/2 s from the real gamez (gamez-lights fixture exists but asserts are on C5's set) +MeshLightSuites.cs mesh-light-fade E C5's authored fade far/slope set and counts +ModeTargetTableSuites.cs mode-target-table E C1 world: ap_transmitter, rearm bases, multiplayer1zep, crates among the hundred-odd destructibles, IA1/MP3 targets.zrd flags +MouseFlightSuites.cs flight-mouse-capture C player_bhawk plane stats only; bare-node rig +MouseFlightSuites.cs flight-mouse-scheme C plane stats only (is_autogyro true on player_autogyro, false on player_bhawk); rig is a bare Node3D model; the control check is shipped-data parity +MouseFlightSuites.cs flight-mouse-scheme-live D pause Preferences leaf over the Original menu layout fixture (Options/CONTROLS page) + player_bhawk stats +MusicSuites.cs music-states D sounds zrdr set (music_splash/prebattle/battle/primaryobj/secondaryobj/tertiaryobj/battlesuccess/missionsuccess + SOUND_GROUPS, sounds.json fixture style) + a SoundArchive stand-in with clips long enough for the 20 s hold/4 s fade +NetAiSpawnSuites.cs net-ai-spawn E C1/M02 generators eairg31/eairg32, authored launch points and due times +NetChatSuites.cs net-flight-chat D [MP-map+messages] three-session MP1 match (net.zrd table >=2 via MatchSpec) + messages.json strings for the To Team prompt; chat rules are otherwise code +NetCombatSuites.cs net-combat-events C [plane+weapons(+MP-launch)] two sessions on MP1 as ground; asserts net rules over zrdr weapon defs (first cannon with health damage) + 2 player planes; no spawn-entry assertions (MatchSpec still demands net.zrd >=2 entries, an empty-stage net launch would drop that) +NetCombatSuites.cs net-kill-line D [MP-map+messages] kill-line texts (Destroyed by / Self-Destroyed / Killed by Turret) and crash key come from messages.json; MP1 + weapons +NetCombatSuites.cs net-match-state D [MP-map] host kill target from zrdr MatchScores + >=3-spawn net.zrd table; match end reasons/boards +NetCombatSuites.cs net-relay-star C [plane+weapons(+MP-launch)] 3 sessions star topology; asserts relay/forwarding counts and own-track vs third plane distance; MP1 only needs >=3 spawns +NetCombatSuites.cs net-spawn-rotation D [MP-map] asserts each seat's opening/return entry on the net.zrd table (real table entries, same on all peers) +NetCombatSuites.cs net-versus-host-left C [plane+weapons(+MP-launch)] lobby doors are code-built NetPlayFeature; MP1 as arena only; host-left notice/close +NetCombatSuites.cs net-versus-lives C [plane+weapons(+MP-launch)] lives rule, spent seat watching host, no-respawn press; MP1 as arena only +NetCoopGuestPlaneSuites.cs net-coop-guest-planes D [campaign-seq+mission] cm_sequence rows (one solo, one wingman mission with chapter folder) + that mission zrdr + planes/loadouts/Custom plane store; asserts plane-pick arbitration and wingman fallback +NetCoopMissionSuites.cs net-coop-cabbie-delivered E C5/M01 OBJECTIVE28 wake and OBJECTIVE20 delivery point, autogyro_1 Cabbie +NetCoopMissionSuites.cs net-coop-intro-field E CM09 (C1/M04) bespoke opening film mission_intro_animation and its piratefighter prop +NetCoopMissionSuites.cs net-coop-mission D [campaign-seq+mission] first cm_sequence mission with a chapter: its chapter gamez/textures + mission zrdr, profile store untouched check; rules are co-op seating/ammo/result +NetCoopMissionSuites.cs net-coop-restart D [campaign-seq+mission] same first-campaign-mission rig as net-coop-mission, pause-sheet restart +NetCoopMissionSuites.cs net-coop-wingman D [campaign-seq+mission] a wingman cm_sequence mission + profile store scratch + wingman def/ammo; asserts host-owned wingman fit on both ends +NetCustomPlaneSuites.cs net-custom-planes C [plane+weapons(+MP-launch)] Allow Custom Planes / Outlaw Components over lobby doors (code); needs hangar parts (zrdr stats/ammo/rocket lists) + planes; MP1 as arena +NetCutsceneSuites.cs net-cutscene-remote-owner E first story mission docking, 'player' marker, auto landing-approach row of the real world +NetCutsceneSuites.cs net-cutscene-skip-episode A two code-built CutsceneControllers over loopback with an invented film name; no data +NetDirectorSuites.cs net-director-follow E C5/M02 director graph, docking code, ending cutscene, objective timers across three BUILT worlds +NetEnetSessionSuites.cs net-enet-join C [plane+weapons(+MP-launch)] real ENet sockets on 127.0.0.1, handshake/roster/seed; MP1 only as arena; planes+zrdr to build sessions +NetFlagSuites.cs net-capture-the-flag D [MP-map(flags)] MP2 map cs_flag_n flag/base markers per team, score_enemy_flag/score_return_flag from zrdr MatchScores; invented MP map with 2 flag records could carry it +NetHumanFieldSuites.cs net-human-field E C3/M01 own script, OBJECTIVE5 'player' TRAVELERS point, host/guest death outcomes +NetLobbyEnvironmentSuites.cs net-lobby-above-the-clouds E C1C MP3 zeppelins.zrd hull starts, multiplayer1zep/2zep, zep_rearm_node_1/2, loading_m3z +NetPlayerVoiceSuites.cs net-player-voice D [MP-map+sound-set] pilot VO sets (Jack, Gruff Male) from the sound archive + sound defs; MP1 + planes +NetPositionalStartSuites.cs net-positional-start E CM02 shipped Balmoral capture rows over BUILT C3/M05 worlds +NetPositionalStartSuites.cs net-swap-rewire C [plane+weapons] airframe-swap code on a guest seat (AirframeSwapCodes is code); needs two planes, zrdr stats/weapons and chapter texture archive (plane skins); C3/MP1 only as arena +NetRangeGateSuites.cs net-range-gate E C4/M03 blacke_drop 64 m range gate, blacke_marker, cutscene codes +NetRearmSuites.cs net-lobby-deathmatch-mp1 E MP1 net.zrd free-for-all block and the rearm bases mp1.gw leaves on +NetRearmSuites.cs net-rearm-deathmatch E MP1's rearm_node_1/2 as authored (serve any pilot), team-3 guest, node radius behaviour +NetRearmSuites.cs net-rearm-zeppelins E MP3 zep_rearm_node_1/2 per team, hull-lost behaviour +NetSeatSuites.cs net-guest-keymap C [plane+weapons(+MP-launch)] guest local players load keymap files (scratch dir written by suite); MP1 arena, planes+zrdr stats/weapons/shakes +NetSeatSuites.cs net-seats D [MP-map] spawn walk places each remote seat on its own net.zrd entry, board rows, roster airframe pick; plus planes gamez and HUD-less remote seat checks +NetSessionSuites.cs net-aircraft-replication C [plane+weapons(+MP-launch)] scripted curve replication error bars over lossy link; MP1 arena only +NetSessionSuites.cs net-pause-overlay C [plane+weapons(+MP-launch)] Built-in pause (code) over a net flight; MP1 arena; offline control already uses --stage=empty +NetSessionSuites.cs net-two-session D [MP-map] asserts both worlds walk every seat onto the same net.zrd table entry; join/seed/roster +NetSoakSuites.cs net-soak C [plane+weapons(+MP-launch)] four link cells, scripted dogfight, kills each way; weapons zrdr; MP1 arena only +NetStartSuites.cs net-start-together C [plane+weapons(+MP-launch)] held start until every guest builds; MP1 arena only +NetSurfaceVehicleSuites.cs net-surface-patrol E C1B/M03's four patrol boats and their nets, chase-lag bar over authored speed +NetTeamSuites.cs net-team-deathmatch D [MP-map(teams)] each seat opens/returns inside its team's net.zrd block ('no second team block' skip), teammate/Ally cycle, score_suicide + team score target from zrdr +NetWorldSuites.cs net-ai-voice D [MP-map(destructible)+sound-set] AI pilot VO ids/accent and attack call-outs (sound archive + sound defs) plus a standing pool matched on both ends +NetWorldSuites.cs net-ai-world D [MP-map(destructible)] --ai=player_pfighter:n=2 on MP1 plus a standing destructible pool the host kills; weapons; needs one authored destructible in the map +NetZeppelinSuites.cs net-zeppelin-path E C3/M01's zeppelins, sampled hull path vs authored speed +NetZeppelinVersusSuites.cs net-zeppelin-vs-zeppelin E MP3 multiplayer1zep/2zep, gas bag/cannon scores from player.zrd, rearm bases, Restart-to-lobby +OptionsLaunchSuites.cs options-difficulty-launch A scratch options.json + SessionSpec/policy only +OptionsLaunchSuites.cs options-targeting-launch A scratch options.json + SessionSpec/policy only +OptionsLaunchSuites.cs options-view-launch A scratch options.json + SessionSpec/policy only +OrdnanceSuites.cs blast-curve-cover-cap C [plane+weapons] splash law 1-d^2/R^2, cover wall, 32-target cap on code-built destructibles; wep_14 splash + a texture archive for the pool +OrdnanceSuites.cs burst-light E he_ground_effect's authored he_light/he_light1 ramp, (104,320) m plateau, 420 m peak, in the real world +OrdnanceSuites.cs burst-light-envelope D [anim-effect-defs] he_ground_effect authored light replaced by burst light; EffectCatalogue + wep_06/09/11 (Enhanced only) +OrdnanceSuites.cs disabling-hits C [plane+weapons] wep_08/09/12 disabling types, wash weights and stun seconds, planes + stats +OrdnanceSuites.cs effect-pool-reset D [anim-effect-defs] sonic_ground_effect def and its ring meshes from the chapter anim program/gamez; pooling logic is the point +OrdnanceSuites.cs effect-pool-spawn-pose D [anim-effect-defs] he_ground_effect debris spawn pose over a four-slot pool; effect def + gamez template nodes +OrdnanceSuites.cs flyout-rack-pose E census over every FLYOUT weapon's reset events plus named reararc/sflsh/rapolys prototypes and rear_flash_effect timings +OrdnanceSuites.cs heat-shimmer D [anim-effect-defs] wep_06 burst takes one shimmer quad; he_ground_effect fireball liveness (Enhanced only) +OrdnanceSuites.cs impact-building-surface E C2 film-lot and C1 hangar/airport building materials, 3040slug_gunhit, bld_damage.flt +OrdnanceSuites.cs impact-orientation D [anim-effect-defs] IMPACT rows he_ground_effect/scatter_effect SURFACE_ANIMATION basis (effect catalogue defs) + wep_06/12 +OrdnanceSuites.cs launch-velocity-decay C [plane+weapons] motor round velocity shed over LOCK_ON; weapon records wep_00/12/14/26 (ids and values 120->180->60 are pinned) +OrdnanceSuites.cs motor-acceleration C [plane+weapons] ACCELERATION to speed cap on wep_04 (150/300/450 pinned) and others +OrdnanceSuites.cs ordnance-end-conditions C [plane+weapons] range/timed-fuse/own-target fuse/unhittable window on wep_24/12/15/14/08 + planes gamez + stats +OrdnanceSuites.cs ordnance-guidance C [plane+weapons] turn rate/speed penalty/paint on wep_11/14/10 + planes; one clause scans every shipped carrier for LOCK_ON_LEAD element 0 +OrdnanceSuites.cs ordnance-impact-effects E default IMPACT row decode (FUN_005ac3a0), flak_effect/ballflare.flt name collision against C1 gamez +OrdnanceSuites.cs ordnance-launch-axis C [plane+weapons] pylon salvo along aircraft axis vs mount axis with markers canted 20 deg; widest rig from planes gamez; wep_13 +OrdnanceSuites.cs shootable-flyout D [anim-effect-defs+messages] FLYOUT torpedo body from chapter gamez prototypes + anim defs (torpedo_trail, torpedo_destroy_effect) + 'Aerial torpedo' label message; wep_14/wep_06 +OrdnanceSuites.cs smoke-screen D [anim-effect-defs] SmokeScreenTunables zrdr + chapter IA1 anim program for the screen/camera anims + wep_13 + planes +OrdnanceSuites.cs sonic-ground-ring E named sonic nodes ring_down1/sonic_puff1/sonic_emit1 and their authored offsets (1 m, 30 m) +PauseBoardSuites.cs pause-board A real PauseBoard code-built, no data +PausePreferencesSuites.cs pause-preferences D [menu-layout+messages] decoded menu layout (MenuLayout), original pause sheet strips from zrdr escape + messages + campaign sequence +PausePreferencesSuites.cs pause-preferences-live-options D [menu-layout+plane] menu layout + PlaneStats player_bhawk; two seats and the Preferences leaf +PauseSheetSuites.cs pause-sheet E each campaign mission's escape.zrd sheet, reference stills flag for flag, C3/M01 and C3/M04 and C4/M05 rows, bitmaps exist in extraction +PauseSheetSuites.cs pause-sheet-ia E ia_escape.zrd's 28 loading_i dialogs, reference still word for word, every named bitmap exists +PauseWorldIconSuites.cs pause-world-icons E CM05 BUILT world, PLAYER_INIT, piratezep hull, chart flag pins within 40 px +PerfHudSuites.cs perf-hud-layout A real PerfHud Full tier drawn in the harness; no data +PlaneWobbleSuites.cs plane-wobble-walk C [plane+weapons] overspeed rattle/nitro shake on a flown plane node: planes gamez + zrdr stats (authored gate) + chapter texture archive +PromptDeviceSuites.cs bindings-prompt-device D [messages] MSG_PRESS_AUTOLAND and glyph/word lines from messages.json; seat/pad logic is code +PromptDeviceSuites.cs hud-auto-dock-line D [messages] auto-dock prompt line text from messages.json; layout anchors are code +PromptDeviceSuites.cs hud-crash-prompt D [messages] respawn prompt text from messages.json; layer/anchor logic is code +PufferSuites.cs emitter-lifetime E C1 refuel*/fire_n_smoke defs and a destructible death in the built C1 +PufferSuites.cs puffer-blend-flag E additive bit of the chapter's shipped texture archive, missile_puffers trailpuffer +PufferSuites.cs puffer-distance-fade E shipped NEAR/FAR fade bands of C3 spew_puffer and volcanosmoke (the cross-wire) +PufferSuites.cs puffer-draw-order A in-code states, recording renderer, farthest-first order +PufferSuites.cs puffer-fire-column E the 30 s fire's authored column height in C1's own upward wind +PufferSuites.cs puffer-fire-glow E chapter's shipped archive: no shipped puffer sprite carries the additive bit +PufferSuites.cs puffer-idle-process-gate D [zrdr-puffer-records] needs PufferState records flame_ball.json/fierypuffer and pufftrails.json/smokepuffer; gating logic only, no pinned values +PufferSuites.cs puffer-modes E asserts authored fierypuffer/smokepuffer/cuepuffer values (NUMBER 18, TIME_INTERVAL 0.2, ...) and every chapter's speed_cue.json +PufferSuites.cs puffer-priority-size A in-code PufferState, recording renderer +PufferSuites.cs puffer-smoke-sun E chapter's shipped archive, smoke vs fire columns +PufferSuites.cs puffer-wind A integration-order and wind coupling on in-code PufferStates and a fake renderer +PufferSuites.cs world-emitter-ambience E C5's own PUFFER_STATE emitters with authored FADE_RANGE and mission wind +RemoteAirframeSuites.cs remote-airframe C [plane+weapons] aeroplane flown from received samples vs identical local rig: planes gamez + zrdr stats/weapons + chapter textures +RenderPoseSuites.cs render-poses A render-pose book on code-built nodes and fixed/realtime clocks +RenderThreadSuites.cs render-thread-handoffs A render-thread timing, texture refill, subtree copy on code-built geometry +ScrapbookFitSuites.cs scrapbook-fit E every scrap of shipped SCRAPBOOK.CSV/LAYOUT.CSV measured in the installed face (census) +SessionStartCoverSuites.cs session-start-cover A cover node layers/fade on code; --det builds none +ShadowQualitySuites.cs display-shadow-quality A shadow-quality precedence, pinned sun/renderer writes, scratch options file; code only +SpawnPropsSuites.cs spawn-props-by-def E autogyro vehicle def names agyro_rotors, CM21 Cabbie def, Warhawk spinprops, choke/restart on the real defs +SpawnPropsSuites.cs spawn-props-silent D [anim-effect-defs+plane+sounds] session roster over a world: spinprops/stopprops anim defs, snd_propstart sound defs + sound archive, planes gamez, zrdr stats; needs the anim program for the prop defs +StaticCameraSuites.cs death-camera C [plane+weapons] already runs on EmptyStage; needs planes gamez, zrdr death_* camera fields, stats/weapons/shakes, C1 textures/zrdr paths +StaticCameraSuites.cs flyby-camera C [plane+weapons] already runs on EmptyStage; planes + zrdr flyby camera fields + textures +StickButtonSuites.cs stick-discrete-buttons A binding rows and tap/hold splitter on code +StuntCaptureSuites.cs stunt-capture E C4/IA1 dz markers, DzRadius, snd_dangerzone_camera sting, stunt scoreboard strip +StuntTargetSuites.cs stunt-target-cycle E C1/IA1 danger zones as objective-class entries with their names +SubmarineHullSuites.cs destructible-sub-hull E C3/M03 barracuda hull block, sub_destruction HEALTH 200, subgen_doors, six-recipient flak count +SurfaceVehicleGunSuites.cs surface-gun-voices E C5/M01 patrol boat and turret truck, snd_turretgun LOOPED_SOUND_NAME, 400 m RANGE/0.3 s refire +SurfaceVehicleGunSuites.cs surface-vehicle-guns E C1B/M03 patrolboat blocks, wep_29 tuple 9000 rounds, def's 400 m ATTACK radius, firepoint marker +SurfaceVehicleSuites.cs campaign-surface-vehicles E CM08 four patrolboat blocks in C1B, CM12 eshipg31/patrolboat_eg0 launch +TargetClassCycleSuites.cs target-class-cycle E C3/M01 piratezep parts, targets.zrd other_target node, five aaguns, ordering +TargetingCandidateSuites.cs ai-pursues-structure C [plane+weapons] mode machine pursue on a code-registered engine; planes, stats, weapons, AiSkills +TargetingCandidateSuites.cs ai-target-rescore C [plane+weapons] 20 s hold, aircraft-first preference, ATTACK-radius reach; player_bhawk/wbloodhawk, weapons +TargetingCandidateSuites.cs aircraft-first-targeting C [plane+weapons] ranking vs decoded target_bias/struct_bias; player_bhawk + wbloodhawk wingman def + turret defs +TargetingCandidateSuites.cs gasbag-ordnance-gate C [plane+weapons] gun-only vs torpedo-armed Warhawk (player_warhawk/bhatwarhawk AI def), DamagesZeppelin weapon flags +TargetingCandidateSuites.cs ranked-pool-carried-turret-dedup C [plane+weapons] hostile aircraft with crewed rear mount (player_kestrel) + turret defs + AiSkills +TargetingCandidateSuites.cs targeting-candidates C [plane+weapons] AI acquisition over a code-registered destructible 'leng11' structure: planes gamez, stock loadout, zrdr stats/weapons/AiSkills, texture archive +TargetingSuites.cs debug-kill-target C [plane+weapons] F17 debug kill routing on aircraft and a destructible: planes + stats + textures +TargetingSuites.cs hostile-marker-hud C [plane+weapons] TargetHud tracker/F16 toggle/debug markers/off-screen geometry on flown planes: planes gamez + stats + textures +TargetingSuites.cs spyglass-marker-hud C [plane+weapons] TargetHud+Spyglass picture gates on flown planes: planes + stats + textures +TargetingSuites.cs target-flag A --target= grammar on tree-free candidates +TargetingSuites.cs target-input C [plane+weapons] tap/hold decode plus a real hostile round through TakeProjectileHit: planes, weapons, stats, chapter textures +TargetingSuites.cs target-pool E includes C1's real emplacements (five aaguns switched on, live and hostile, reach no cycle) via a private C1 world +TargetingSuites.cs target-ref A tree-free TargetRef/Classify/label formats, no data +TargetingSuites.cs target-selection A tree-free sticky selection and cycle order on AimCandidates +TurretHullBlockSuites.cs turret-hull-blocks-own-fire E C1/M04's real piratezep: every emplacement gimbal-swept for its first hull-blocked direction +TurretMountSightlineSuites.cs turret-grouped-site-sightline C [weapons+turret-defs] same code-built aagun site under a group node; zrdr turret/weapon defs only +TurretMountSightlineSuites.cs turret-own-mount-sightline C [weapons+turret-defs] aagun-pattern site under a bare root with a 4 m box; needs zrdr turret def + weapon def and a texture archive for the pool; no plane +TurretStructureSuites.cs turret-structure-targets E C1/M05's hospital ship (redcross) flagged scene node ownership for the mission +TurretVesselSuites.cs turret-vessel-targets E C1B/M03's four woken patrol boats, hostility against hulls' own team +TurretVoiceSuites.cs turret-gun-voices E every turret the data gives a voice over C4: piratezep rings, snd_chaingun, trucks and twin cannon silent +VersusSpawnSuites.cs versus-spawn-net-table D [MP-map] MP1 net.zrd free-for-all block read by SpawnPicker, multiplayer throttle/speed; pure zrdr reading, no world needed +VersusSpawnSuites.cs versus-spawn-rotation D [mission-zrdr(IA1 spawn list)] needs a mission zrdr with a dogfight_ace ia.json spawn list (>=3 points) + planes; rule is the rotation geometry +VesselFuseSuites.cs vessel-fuse E CM08 patrolboat_1 in C1B: proximity fuse against the real hull and its destructible pool +WarhawkTorpedoRunSuites.cs warhawk-torpedo-run E C4/M03's own opening geometry, authored 350-800 m torpedo band +WeaponSelectorSuites.cs weapon-selector-input A F3-F6 and pad class buttons routed on code +WindStreakSuites.cs wind-streaks A enhanced wind-streak uniforms follow flight; code-built +WingWalkCameraSuites.cs campaign-wingwalk-camera E CM02 capture cutscene (britbalmoral_1, ww_balmoral1, camera1) over its BUILT world +WingmanFitSuites.cs campaign-wingman-fit E census: nearly every shipped wingman def authors a weapons block; Fury/Balmoral wingman defs, wep_12 +WingmanSuites.cs wingman-engage E every escorting block CM02 plans (C3/M05 roster, rating_biases, attack gates) +WingmanSuites.cs wingman-lost-leader E CM05 pairing devastator_1 on M4Bravo#11 net, deactivated leader hand-off +WingmanSuites.cs wingman-station C [plane+weapons] decoded escort station law as geometry then flown: planes gamez, stats, wingman AI def, chapter textures; no mission +WorldAndToolSuites.cs alpha-coverage-text E built world's every cutout/blend surface and C5 clutter sprite cards (shader census, both presentations) +WorldAndToolSuites.cs chapter-census E every chapter's built world: colliders, partition-cell ground rays, pinned destructible totals, 8-inch cannons, mip LOD bias +WorldAndToolSuites.cs clutter-card-pose E C5 poleflare facade look-at vs lightpole cylindrical pose, cblock7 +WorldAndToolSuites.cs clutter-determinism E C1 templates.zrd substitute/scale_range species mix, instance total, interp.json templates +WorldAndToolSuites.cs clutter-mesh-lift E C5 cblock7 lamp glow stamps 4.75 m up its decoration chain +WorldAndToolSuites.cs cockpit-interior C [plane+weapons] player plane cockpit1 interior at cockpit_camera marker, per-mode hiding: planes gamez node cockpit1 + marker + textures +WorldAndToolSuites.cs cockpit-overlay-pass C [plane+weapons] cockpit pass moves the interior into its own world: planes gamez + textures +WorldAndToolSuites.cs cockpit-sun-bearing D [mission-zrdr(weather)] mission zrdr SUNLIGHT_ORIENTATION per zone (C1/IA1 ZONE1 -25, C2/MP2 cross); cockpit pass nodes in code +WorldAndToolSuites.cs danger-zone-photograph-fill D [plane+mission-zrdr] WeatherState from C1/IA1 mission zrdr (decoded fill ambient) + planes + textures +WorldAndToolSuites.cs fade-walk-bound A WorldCollision fade re-derive on code-built node chain +WorldAndToolSuites.cs gltf-export C [plane+weapons] viewer plane exports to glTF textured and wound out: planes gamez + texture archive +WorldAndToolSuites.cs lens-flare-gates E gamez sun node vs init.gw LensFlareTexture slots agreeing chapter by chapter (C2 and C3 only) +WorldAndToolSuites.cs nodelab-visibility D [gamez-destructible] needs a world with a destructible that has a 'healthy' variant and RESET_STATE; the world is used for its anim def, not just as a tree +WorldAndToolSuites.cs plane-shader-reuse C [plane+weapons] second aircraft build reuses Shader resources: planes gamez + texture archive +WorldAndToolSuites.cs scene-build-throw-frees C [plane+weapons] subtree build that throws frees its meshes: planes gamez + texture archive +WorldAndToolSuites.cs sky-sprite-backdrop-key E C4 moon1, C5 star1 corners, C2 moonbackdrop/moonsurface left alone +WorldAndToolSuites.cs splitscreen-listeners A SplitScreen.Build 2-4P panes are audio listeners, code only +WorldAndToolSuites.cs sun-energy E C1B night against C1C day mission: the install's own pair, plus C1C/MP1 edge case +WorldAndToolSuites.cs sun-orientation D [mission-zrdr(weather)] mission zrdr with ZONE1/ZONE2 SUNLIGHT_ORIENTATION and a cloud band (19024-20124 m); WeatherRig on code nodes; C2/MP2 values pinned +WorldAndToolSuites.cs terrain-pick-export D [terrain-world] needs map-scale terrain mesh tiles + CloudDeck (CollectMapScale) for click-pick and glTF export; C4 tile counts not pinned +WorldAndToolSuites.cs tex-dropin D [texture-archive] TextureDropIn.Flatten over named archive textures (DropInSamples) from a chapter texture archive; needs a small archive with those names +WorldAndToolSuites.cs texture-alpha-class E every chapter's texture archive alpha classification from the manifest; named textures poleflare/lightpole/cblock1/bldg1/wtr00000 +WorldAndToolSuites.cs texture-alpha-nibble E C5 flare_green 4-bit alpha on every mip, cloud1 bit 3 +WorldAndToolSuites.cs weather-cockpit-whiteout D [mission-zrdr(weather)] mission zrdr cloud band (C1/IA1 970-1124 m, core 1032-1062 m) and fog; CockpitOverlay on code nodes +WorldAndToolSuites.cs wing-flare-pose C [plane+weapons] player_pfighter wing-light flares look-at pose and authored falloff pair: planes gamez + textures +WorldAndToolSuites.cs world-lights-enhanced-budget A WorldLights omni pool budgets on synthetic positions +WorldAndToolSuites.cs world-lights-nearest-viewer A WorldLights on synthetic positions +WorldFidelitySuites.cs anim-clock-realtime A AnimRuntime callbacks per clock mode on an empty program +WorldFidelitySuites.cs campaign-submarine E CM04 authored-inactive barracuda in C3/M03 and sub_movement +WorldFidelitySuites.cs fog-state E C1/M04 intro definition FOG_STATE drop_fog values +WorldFidelitySuites.cs generator-launch-climb-out E C1/M02 eairg31 real airfield with colliders, 30 s climb-out +WorldFidelitySuites.cs generator-takeoff-run E C1/M02 eairg31 eag31_aip points and take-off run +WorldFidelitySuites.cs hangar-door-wake E C1/M04 OBJECTIVE1 WAKE_ANIM hangar3_doors, 50 m panels, eairg_open31, 4 s lead +WorldFidelitySuites.cs partition-areas E C3's own three story rectangles, C3/M01 third area switched off +WorldFidelitySuites.cs scripted-path E C1/M04 authored pp1 takeoff path, 40 mph taxi, decoded 300 m point +WorldFidelitySuites.cs world-node-active-depth E C1's bbtur11/healthy and attack_balloon11 active bits +WorldQueryReuseSuites.cs world-query-reuse A GodotWorldQuery ray/overlap reuse against one code-built block +WorldSoundSuites.cs world-sound-falloff D [sound-set] SoundDefs/groups RANGE curve + a sound archive entry for a pooled emitter; invented def and a tiny wav archive would do +ZeppelinBreakupSuites.cs zeppelin-breakup E C1/M04 piratezep killpzep, NODE_UNDERCOVER 65 m, six gasbag drops, twelve engines +ZeppelinCannonBurnoutSuites.cs dante-cannon-burnout E C5/M04 Dante among nine other zeppelins sharing node names, OBJECTIVE10 +ZeppelinCannonBurnoutSuites.cs zeppelin-cannon-burnout E C1/M04 hk_zep sabotaged doors, finish_locklear, lkgasbag05 +ZeppelinCannonStowedSuites.cs zeppelin-cannon-stowed E C2B/M04 Gemini lbroad11 deploy def RESET_STATE, HEALTH 60 +ZeppelinDormantDrawSuites.cs zeppelin-dormant-draws-nothing E C4/M05 CM20 blackhatzep deactivated record +ZeppelinFuseSuites.cs zeppelin-fuse E C1/M04 piratezep top, DETONATION_DISTANCE pass +ZeppelinHullActivationSuites.cs zeppelin-hull-activation E C2/M03 Pandora piratezep hidden gamez bit, pzhookpoint, pzhomebase dock +ZeppelinIdentitySuites.cs structure-part-bias E C4/M03 cargozep1 pools, Black Swan escort -1.0 and Black Hat 1.0 biases +ZeppelinIdentitySuites.cs zeppelin-identity E C5/M03 cargozep* exclusion; C1/MP3 record team on ally hull emplacements +ZeppelinLibraryCopyHitchSuites.cs zeppelin-library-copy-hitch E C4/M05 cargozep3 library-root callee copy cost on the real world +ZeppelinPanelSwapSuites.cs zeppelin-panel-swap E C2B/M04 pzepleft_gasbag1 panel/burnt twin cull modes +ZeppelinSuites.cs zeppelin-broadside E C3/M03 Pandora, C1/M04 flying piratezep wep_28 volleys, C5/M04 Dante twelve cannons +ZeppelinSuites.cs zeppelin-damage E C1/M04 piratezep gasbag 120/engine 40 pools, survivor threshold polarity +ZeppelinSuites.cs zeppelin-launch E C1/IA1 zeppelin-launch generator, cargobay origin, mp1 door anims, 7 s schedule +ZeppelinSuites.cs zeppelin-motion E C1/M04 piratezep PirateZep1 node 0 stop points, max_speed, sqrt curve +ZeppelinSuites.cs zeppelin-pandora-dead-end E CM08 piratezep Klondike1 chain stops 7/8, zepgetcargo +ZeppelinSuites.cs zeppelin-scripted-pose E CM04 pzep_todrydock 61.65 s over C3/M03 +ZeppelinZoneTeamSuites.cs zeppelin-zone-team E C4/M03 Pandora gasbag/engine pool teams off flagged nodes diff --git a/analysis/engine-suite-weights.json b/analysis/engine-suite-weights.json index 04e370920..d2c900dff 100644 --- a/analysis/engine-suite-weights.json +++ b/analysis/engine-suite-weights.json @@ -100,6 +100,7 @@ "campaign-launch-hook": 4.445, "campaign-launch-hook-prop": 1.425, "campaign-layout-parity": 0.343, + "campaign-layout-core": 0.136, "campaign-loop": 1.541, "campaign-militia-livery": 1.79, "campaign-mission-cash": 0.415, @@ -232,12 +233,16 @@ "generator-takeoff-run": 1.628, "gltf-export": 0.55, "graphics-live-switch": 8.347, + "graphics-ocean-switch": 11.4, + "graphics-retext-compiles": 18.3, "graphics-shader-twins": 3.192, "graphics-switch-cover": 0.026, "graphics-view-distance": 9.3, + "graphics-water-quality": 7.1, "graze-bounce": 0.483, "graze-bounce-bot": 3.64, "ground-contact": 1.257, + "ground-contact-core": 0.022, "gun-voice-reach": 1.83, "hangar-door-wake": 2.993, "heat-shimmer": 0.044, @@ -283,9 +288,11 @@ "menu-controls-seats": 0.17, "menu-free-flight-journey": 0.27, "menu-hangar-journey": 1.087, + "menu-hangar-journey-core": 0.778, "menu-host-pointer": 0.075, "menu-host-tracer": 0.136, "menu-instant-action-journey": 0.758, + "menu-instant-action-journey-core": 0.527, "menu-join-board": 0.08, "menu-join-board-bots": 0.058, "menu-launch-return": 0.664, @@ -395,6 +402,8 @@ "nitro-boost-flown-rig": 0.597, "nodelab-visibility": 1.005, "nulled-launch": 1.009, + "ocean-harbour-ramp": 9.85, + "ocean-lab": 5.16, "options-difficulty-launch": 0.02, "options-targeting-launch": 0.019, "options-view-launch": 0.022, @@ -484,6 +493,7 @@ "versus-local-bot": 13.386, "versus-local-bot-graze": 2.488, "versus-local-crash-respawn": 10.748, + "versus-spawn-empty-stage": 0.03, "versus-spawn-net-table": 0.017, "versus-spawn-rotation": 0.396, "vessel-fuse": 1.288, diff --git a/analysis/goldens/README.md b/analysis/goldens/README.md index eb0e01816..8828c4127 100644 --- a/analysis/goldens/README.md +++ b/analysis/goldens/README.md @@ -82,10 +82,12 @@ perturbation (`c1-flight-kill` 78.07 %, `campaign-4p-grid` 73.79 %, `c1-cockpit` `empty-stage` 12.21 %, `c4-snow` 3.74 %, `campaign-intro-fill` 3.89 %, `c1c-rain` 2.50 %, `c1-waterfall` 1.28 %); the rest are geometry-and-shading shots and say so. -**The five `-enhanced` shots pin the Enhanced Graphics stack** (`--graphics=enhanced` in their own +**The six `-enhanced` shots pin the Enhanced Graphics stack** (`--graphics=enhanced` in their own args, which `--det` honours while it drops the saved mode): the lake's reflection and sun shadows, -the night city's omni pool, the deck's lit puffs, a rocket burst's lit effects, -and the cockpit pass. Every other shot is the faithful path, and `RunTests.ps1 -Graphics` reaches +the night city's omni pool, the deck's lit puffs, a rocket burst's lit effects, the cockpit pass, +and the wave ocean. `c1b-ocean-enhanced` passes `--water-quality=waves` itself, so a change to the +water-quality ladder cannot turn its subject off; the lake, the night city and the rocket shot also +draw the ocean, through `--det`'s own `waves` fallback. Every other shot is the faithful path, and `RunTests.ps1 -Graphics` reaches none of them. Their frame sensitivity is mostly TAA's jitter: a static freecam pose moves 5.77 % to 24.69 % from one frame to the next, against 0.01 % (the C5 pose) and 1.04 % (the lake, its falls scrolling) with TAA forced off, so the hash pins the jitter phase along with the picture. That phase diff --git a/analysis/goldens/manifest.json b/analysis/goldens/manifest.json index 642a85f71..b380813a1 100644 --- a/analysis/goldens/manifest.json +++ b/analysis/goldens/manifest.json @@ -148,15 +148,15 @@ { "name": "c1-lake-enhanced", "frame": 120, - "hash": "205619d975c85f44ebbccfd6b4a74d15", - "exercises": "Enhanced graphics over C1's waterfall lake: the blurred screen-space reflection on the water, conifers casting sun shadows onto shadowless terrain, SSAO, and TAA at its settled jitter phase; frame-sensitive 23.53 %.", + "hash": "b7e66c00633d4872c3e6427c3e9c3c25", + "exercises": "Enhanced graphics over C1's waterfall lake: the wave ocean in the open lake, calmed to the flat sheet's shading at the coast tiles, blurred SSR, conifers casting sun shadows, SSAO, settled TAA; frame-sensitive 24.09 %.", "args": ["--freecam", "--chapter=C1", "--pos=-7620,140,-3240", "--lookat=-7868,40,-3449", "--det", "--mute", "--graphics=enhanced"] }, { "name": "c5-city-night-enhanced", "frame": 120, - "hash": "7fe15f15527dc356857add7c0d5876c2", - "exercises": "Enhanced graphics over the night city from 178 m: authored lights on the pooled omni lights, lit facades under the enhanced sun and ambient, TAA, well outside the whiteout; frame-sensitive 24.69 %.", + "hash": "d36e8944eb413700e80a4b888714b86f", + "exercises": "Enhanced graphics over the night city from 178 m: authored lights on the pooled omni lights, lit facades under the enhanced sun and ambient, the wave ocean on the far waterline, TAA, well outside the whiteout; frame-sensitive 24.69 %.", "args": ["--freecam", "--chapter=C5", "--pos=-9256,178,-3155", "--direction=-0.588,-0.1,-0.809", "--det", "--mute", "--graphics=enhanced"] }, { @@ -169,8 +169,8 @@ { "name": "c1-rocket-hit-enhanced", "frame": 90, - "hash": "7534fd874ad6bfa91de257dd7fece809", - "exercises": "Enhanced chase flight after two HE rockets hit the dirt by C1's airfield: the burst omni light on the ground, blooming fire frames, sun-shaded smoke, heat shimmer and scorch pools live, wind streaks, TAA; frame-sensitive 94.08 %.", + "hash": "e2646781df55d23311e7131e7a56151f", + "exercises": "Enhanced chase flight after two HE rockets hit the dirt by C1's airfield: the burst omni light, blooming fire frames, sun-shaded smoke, heat shimmer and scorch pools, wind streaks, the wave ocean past the coast, TAA; frame-sensitive 89.72 %.", "args": ["--chapter=C1", "--plane=player_bhawk", "--pos=-6104,300,-4420", "--direction=0,-0.33,0.94", "--fire-rockets", "--hold=0,0,0,0.3", "--det", "--mute", "--graphics=enhanced"] }, { @@ -179,6 +179,13 @@ "hash": "366314f638592ae6b7c42f3a97ed10bd", "exercises": "Enhanced graphics from the pilot's seat in held pitch-and-roll flight: the shaded cockpit1 interior in its transparent pass with MSAA and no TAA, over the enhanced C1 world with TAA; frame-sensitive 41.10 %.", "args": ["--chapter=C1", "--plane=player_bhawk", "--view=cockpit", "--hold=0.2,0.1,0,1", "--det", "--mute", "--graphics=enhanced"] + }, + { + "name": "c1b-ocean-enhanced", + "frame": 120, + "hash": "39600594889f1eb291563bf5168e2a63", + "exercises": "Enhanced wave ocean (water quality waves) over C1B's night islands from 120 m: bent swell, noise detail, foam, the waves calming toward each shore, the surf ring, SSR, TAA, in the shipped C1B sea.", + "args": ["--freecam", "--chapter=C1B", "--pos=-5900,120,-8350", "--direction=0,-0.3,1", "--det", "--mute", "--graphics=enhanced", "--water-quality=waves"] } ] } diff --git a/analysis/headless-limits.json b/analysis/headless-limits.json new file mode 100644 index 000000000..bb1b9879f --- /dev/null +++ b/analysis/headless-limits.json @@ -0,0 +1,31 @@ +{ + "schema": 1, + "readme": "What a headless Godot process cannot do, on any platform. sandbox/LinuxRelease.ps1 and RunCiSuites.ps1 both read this file, so the two headless runs share one list and neither grows its own. headlessOnly names the suites that cannot pass headless by construction, each with the reason: each reads back something only a real renderer or a real display produces. Every entry was run headless from the Windows export of the same commit against a Windows extraction of the same shape and failed there too, so an entry here is never a Linux finding. LinuxRelease.ps1 still runs them and reports one that passes, so a stale entry is seen; none may be on SuiteCatalog.CiTier, which a unit test checks. headlessEngineErrors maps a regex to the reason for engine error lines a headless process prints and a windowed one does not: the same run from the Windows export prints each, at the same counts. An intermittent engine race cannot meet that, so its evidence is a game-free reproduction and the upstream issue, both named in its reason. The harness's own allowlist is written for the windowed battery, so a headless run applies these to the report's unexpected lines, by pattern, and every other unexpected line still fails it. Add an entry only on that evidence, never to turn a run green.", + "headlessOnly": { + "build-stamp-focus": "pushes a click at the logs icon's laid-out centre, which a headless window of no size places outside the viewport", + "cloud-field-fade": "reads the cloud cards' normals back out of the built mesh, which the headless dummy renderer does not store", + "clutter-activation": "reads each stamp's drawn state back from the clutter MultiMesh's instance transforms, which the headless renderer keeps no instances for", + "clutter-cells": "reads every placement back through the clutter cells' MultiMesh instance transforms, which the headless renderer keeps no instances for", + "clutter-card-depth": "counts rendered pixels in a viewport; headless renders none", + "display-mode": "asserts on the window mode of a real window; the headless display server has none", + "display-monitor": "enumerates screens; the headless display server reports none", + "display-resolution": "reads the screen size; the headless display server reports 0x0", + "display-vsync": "applies V-Sync to a real window's swap chain; headless has none", + "fog-per-view": "reads each splitscreen pane's rendered fog colour back from its viewport; headless renders none", + "graphics-live-switch": "reads the drawn clutter buffers and every texture's image back after each switch, which the headless renderer keeps neither of", + "menu-original-tracer": "reads the OS pointer's visibility and the monitor list from the display server, which headless does not provide", + "menu-screenshot-key": "captures the viewport texture to a PNG; headless draws nothing, so every file is empty", + "muzzle-flash-rides-muzzle": "reads the flash quads' drawn instance transforms back, which the headless renderer does not keep", + "puffer-fire-glow": "reads each emitter column's MultiMesh instance custom data back, which the headless renderer does not keep", + "puffer-smoke-sun": "reads each emitter column's MultiMesh instance custom data back, which the headless renderer does not keep", + "sun-per-view": "counts each splitscreen pane's rendered aircraft pixels and reads their shade back; headless renders none", + "trail-world-anchor": "reads the first drawn puff's instance transform back, which the headless renderer does not keep", + "wing-flare-pose": "counts the flare's rendered pixels in a viewport; headless renders none" + }, + "headlessEngineErrors": { + "texture_2d_get \\(\\./servers/rendering/dummy/": "the dummy renderer holds no texture data to read back", + "(_shaped_text_add_string|_ensure_cache_for_size|_font_get_(ascent|descent)) \\(modules/text_server_adv/": "a headless font has no rasterised size cache, so shaping at a measured size of 0 fails", + "The new image dimensions must match the texture size\\. at: update \\(scene/resources/image_texture\\.cpp": "a graphics switch re-uploads an alpha-depth texture in place (TextureUpload.Replace), and a headless process reports a size mismatch the windowed battery does not", + "Parameter \"(material|m)\" is null\\. at: \\w+ \\(servers/rendering/dummy/storage/(material|mesh)_storage\\.(cpp|h)": "the dummy renderer keeps materials and meshes in RID owners that are not thread-safe in Godot 4.7, so under the separate render thread a lookup intermittently misses a live RID; a game-free GDScript loop reproduces it, and it prints at no fixed count because it is a race (godotengine/godot#121949, fixed by #121958 for 4.8; remove this entry when the project moves to 4.8)" + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0299fcaad..95778e992 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -330,6 +330,14 @@ rows). - `src/Effects/PufferEmitterFactory.cs`, the animation runtime's `IEmitterFactory` seam implemented over `Puffer`, one per built world. - `src/Effects/EmitterRenderer.cs`, the `IEmitterRenderer` seam under `Puffer` and the `MultiMesh` billboard-shader renderer behind it. - `src/Effects/FogVolumeClutter.cs`, the authored ambient cloud field: `fogvol.zrd` clutter scattered through its `fvol*` volumes, one MultiMesh per kind. +- `src/Effects/Ocean.cs`, the Enhanced wave ocean on every chapter with a sea at y = 0: a camera-centred Gerstner grid in place of the flat sea-level sheet. +- `src/Effects/OceanCalmZone.cs`, one ship's calm zone on the wave ocean: a box along the hull's heading over its waterline and wake sheets. +- `src/Effects/OceanMask.cs`, the wave ocean's shore mask, baked from the built world's water and solid polygons. +- `src/Effects/OceanMaskRaster.cs`, the shore mask's texels from its triangles, in parallel row bands that give identical bytes. +- `src/Effects/OceanMovers.cs`, the rule for the boats an animation carries across the sea, which the ocean calms around wherever they float. +- `src/Effects/OceanSeas.cs`, the shipped per-chapter seas in `CSVM/data/ocean_seas.json`: read with warnings, and one chapter's entry written by the ocean lab. +- `src/Effects/OceanShader.cs`, the wave ocean's shader text generated from one sea state, byte-identical to the tune at the defaults. +- `src/Effects/SeaState.cs`, one chapter's sea: every tunable of the wave ocean with its default, range and lab group, clamped to the fold limit. - `src/Effects/Precipitation.cs`, weather.json rain/snow: one camera-following MultiMesh of flakes or streaks, self-animating on the GPU. - `src/Effects/ScorchField.cs`, the enhanced presentation's scorch marks: a capped pool of decals with one procedural burn texture, laid over the crater carve. - `src/Effects/WindStreaks.cs`, the enhanced presentation's camera-local wind streaks, keyed to airspeed and load factor, over the authored speed cue. @@ -477,6 +485,7 @@ the `UI.Menu` presentation tree. `Campaign`, `Screens`, `Overlays` and `Labs` ar - `src/UI/Labs/MeshLab.cs`, the geometry and shading lab (M): normal lines, smoothing seams, cull and normal overrides, on the parked plane or on the selection. - `src/UI/Labs/WeaponLab.cs`, the weapon lab panel (B): steppers that arm the held plane's live loadout, and click-to-place on a world surface. Fires nothing. - `src/UI/Labs/NodeLab.cs`, the node lab (N): a lazy `cs_name` tree, search, frame, hide and glTF export, a dependency readout and a destructibles view. +- `src/UI/Labs/OceanLab.cs`, the ocean lab (Shift+F1, `--freecam` only): a slider per sea field, applied live, with Save into the shipped seas file. - `src/UI/Labs/WorldDamageLab.cs`, the world damage lab (F19): an HP slider with kill and reset on the selection's own destructible pool. - `src/UI/Labs/AnimLab.cs`, the `--anim-lab` debugger: a quiet stage, a fixed-dt clock, a transport panel, a def picker, the timeline and a freecam. - `src/UI/Labs/AnimTimeline.cs`, the anim lab's per-sequence timeline: authored event blocks against runtime-fired ticks, the scheduler-divergence instrument. @@ -551,6 +560,7 @@ the `UI.Menu` presentation tree. `Campaign`, `Screens`, `Overlays` and `Labs` ar - `src/UI/Menu/Original/OriginalPresentation.cs`, the Original presentation node: the shell drawn through `ComposedBoardView`, seats polled. - `src/UI/Menu/Original/OriginalArtSizes.cs`, the art measurer every `OriginalShell` host hands it: one art name answered with its pixel size, cached, a movie's read off its sequence header. - `src/UI/Menu/Original/OriginalAvailability.cs`, Original's availability answer before entry: a refusal reason, or the loaded layout. +- `src/UI/Menu/Original/SyntheticShell.cs`, the synthetic tree's Original shell: the fixture layout decoded, invented string rows, one generated picture per recorded art size, and the whole tree's family list. - `src/UI/Menu/Original/OriginalAssetManifest.cs`, the required/optional file manifest derived from the layout, the backdrop movies among the optional, and the check over a tree. - `src/UI/Menu/Original/OriginalRosters.cs`, the Original sortie screens' chapter labels and the eleven stock airframes with their nodes. - `src/UI/Menu/Original/OriginalCues.cs`, the four cue names Original asks for: a rollover, a press, and an edit box's two sounds. @@ -614,7 +624,8 @@ determinism repo-wide; read `docs/verification.md` first. - `src/Utils/ShadowQualitySetting.cs`, the Enhanced sun's shadow quality (off, low, medium, high, ultra): the flag/saved/config `WordSetting` lookup over ultra (high on an integrated GPU, off there at three or four panes), and what each level writes on the sun and the renderer. - `src/Utils/WallCostBank.cs`, one `--perf` cost meter (bracket, banked milliseconds, worst span, count, tally) and the bracket node; the three cost facades are instances of it. - `src/Utils/ViewDistance.cs`, enhanced mode's view distance: how much further clutter draws before its fade, the fog untouched, switched live on an apply. -- `src/Utils/WordSetting.cs`, the flag/saved/config/fallback lookup the four word-valued graphics settings share, its invalid-config warning and the `SettingSource` a log line names. +- `src/Utils/WaterQualitySetting.cs`, the Enhanced sea's water quality (flat, waves): the flag/saved/config `WordSetting` lookup over waves (flat on Linux or an integrated GPU), which the session's wave ocean follows live. +- `src/Utils/WordSetting.cs`, the flag/saved/config/fallback lookup the five word-valued graphics settings share, its invalid-config warning and the `SettingSource` a log line names. - `src/Utils/SunShadow.cs`, the shadow settings one directional light hands another, which the cockpit pass and the enhanced look share. - `src/Utils/ViewportQuality.cs`, what the anti-aliasing method and the render scale write on a 3D viewport, in one call the four viewport construction sites share. - `src/Utils/VSyncSetting.cs`, the frame pacing: the flag/saved/config ladder, and the one place the vsync mode and the frame cap are applied to the engine. @@ -637,15 +648,15 @@ determinism repo-wide; read `docs/verification.md` first. - `src/Testing/PhaseAttribution.cs`, buckets a build's `StartupProfile` phases into archive/decode, sound preparation and world construction for the report. - `src/Testing/CountingEmitterFactory.cs`, the no-GPU `IEmitterFactory` fake a suite installs to observe `PUFFER_STATE` emitter lifetime. - `src/Testing/RecordingEmitterRenderer.cs`, the no-GPU `IEmitterRenderer` fake: keeps a `Puffer`'s particles instead of drawing, so its modes are testable. -- `src/Testing/SuiteCatalog.cs`, the registry of the in-engine suites, discovered from the `[Suite]` attribute on each body and ordered by name. +- `src/Testing/SuiteCatalog.cs`, the registry of the in-engine suites, discovered from the `[Suite]` attribute on each body and ordered by name, and the checked-in `quick` and `ci` tiers. - `src/Testing/*Suites.cs`, the domain scenario modules holding the marked suite bodies: puffer, combat, ordnance, Instant Action, AI, campaign, zeppelins. - `src/Testing/SuiteConstants.cs` / `BurstTimeline.cs` / `SuiteViewers.cs` / `EffectStageSuiteHelper.cs` / `BotSuiteHelper.cs`, shared golden inputs, timeline values and fixtures. - `src/Testing/MenuSuiteHost.cs`, the launchscreen fixture a menu suite builds on: a `MenuHost` with the launcher's features, one seat and silent audio. ### `src/Tooling/`, runtime tooling the game and the harness share -The `--dump-*` probes, the capture loop, the golden-image hash and the glTF export. The game and -`src/Testing/` both depend on it; it reaches into the harness only to dispatch `--run-tests`. +The `--dump-*` probes, the capture loop, the golden-image hash, the glTF export and the +`--synthetic-data` tree. The game and `src/Testing/` both depend on it; it reaches into the harness only to dispatch `--run-tests`. - `src/Tooling/Probes.cs`, the assertion cores behind the `--dump-*` reports: one pass yields the report text and the verdict a suite asserts on. - `src/Tooling/EnvelopeMargins.cs`, one flight scenario's distance from every term that could bound it, plus the decoded branches it drove. @@ -654,6 +665,14 @@ The `--dump-*` probes, the capture loop, the golden-image hash and the glTF expo - `src/Tooling/ShaderDiagnostics.cs`, `--debug-shaders`: the shader census, the frames after a live switch and every frame over 33 ms. - `src/Tooling/CaptureDirector.cs`, the `--screenshot=`/`--shots=`/`--frames=` capture state machine, F11's camera-pose print and F12's save, ticked from `_Process`. - `src/Tooling/GltfExporter.cs`, exports the viewer plane subtree to glTF (mesh + livery + baked damage) for `--export-gltf=`/F10, on a throwaway duplicate. +- `src/Tooling/SyntheticData.cs`, `--synthetic-data`: writes an invented `extracted/` tree into scratch from the fixture records and generated files, stamped synthetic. +- `src/Tooling/SyntheticTextures.cs`, the synthetic tree's C1 texture archive: the fixture manifest plus one generated PNG per entry. +- `src/Tooling/SyntheticPlane.cs`, the synthetic tree's stand-in aircraft `probe_plane`: its box-built model, plane records, one gun, one rocket, shakes and messages. +- `src/Tooling/SyntheticImages.cs`, the flat-block JPEG and uncompressed TGA writers the synthetic menu art needs beside PNG. +- `src/Tooling/SyntheticSounds.cs`, the synthetic tree's sound archive: the fixture `sounds.json` plus one generated ADPCM WAV per manifest entry, and the `voice.json` accent table. +- `src/Tooling/SyntheticMission.cs`, the synthetic tree's one invented mission scope `C1/PROBE1`: its `weather.json` and `net.json`. +- `src/Tooling/SyntheticEffects.cs`, the synthetic tree's anim and effect records: a box-built template gamez, a compiled anim archive, reader destructibles and two puffer readers. +- `src/Tooling/WavWriter.cs`, encodes mono samples as a PCM or MS ADPCM WAV in the layout `WavFile` decodes. ### `src/Launch/`, the composition root @@ -664,7 +683,7 @@ The process and the per-launch session: the top family bar `Testing`, so nothing - `src/Launch/BuildState.cs`, the per-build state the session's ordered steps share: paths, archives, the world build's outputs and the running counts. - `src/Launch/SkyStage.cs`, the sky build step: the weather rig with each rig's domes and deck, the cloud field and banks, and the lens flare. - `src/Launch/SessionProbes.cs`, the build's scripted probes that report and quit, and the `--destroy=`/`--debug-objective=` build-time forces. -- `src/Launch/InspectionLabs.cs`, the build's inspection steps: the parked-plane view and its labs, freecam, anim lab, selection labs, flight labs and debug overlays. +- `src/Launch/InspectionLabs.cs`, the build's inspection steps: the parked-plane view and its labs, freecam, anim lab, selection labs, the ocean lab, flight labs and debug overlays. - `src/Launch/SessionBoards.cs`, the whole-window boards over a flight: the pause board and options leaf, the results boards, the menu readers and photo mode. - `src/Launch/SessionNet.cs`, the session's end of the wire: the join, the seat list, the state relay, combat, chat, the clock and start gate, and the world links. - `src/Launch/ProjectileStage.cs`, the flight's shared projectile pool build step and its world, crater, scorch and wash sinks. diff --git a/docs/architecture/Effects.md b/docs/architecture/Effects.md index 069cb4bd4..bba73213d 100644 --- a/docs/architecture/Effects.md +++ b/docs/architecture/Effects.md @@ -52,6 +52,70 @@ and the one draw both `far_fade_range` pairs are interpolated with into a custom `csky_clutter_fade_alpha_angled` turns into the view-angle fade; that draw takes its own `Rng.CloudBands` stream. The shipped field is that decoded lattice plus a remake-only X/Z offset per card (`ShippedJitter`, 30 m, overridden by `--cloud-jitter=`), drawn off `Rng.CloudJitter` and reaching no other population. The quad is posed by `csky_facade_spherical` (`shaders/csky_facade.gdshaderinc`), a world-up look-at standing in for the original's SphericalY tracker, which reads the eye's position and not its basis, so neither the camera's roll nor a sideways move turns a card ([../org/cloudCards.md](../org/cloudCards.md)). A `lighting: true` card (C1C, C2B, C5) carries its three authored normals and takes the original's per-vertex `AMBIENT + DIFFUSE x max(N.L, 0)` through that same pose off `WeatherRig`'s uncollapsed globals, never `csky_world_light` ([../org/vertexLighting.md](../org/vertexLighting.md)). Under `GraphicsMode.Enhanced` alone, `ShaderCode` layers a grade by the global `csky_sun_dir` over either variant, leaving the faithful and lit text byte-identical, and draws both kinds from the deck pool of rendered puffs (`Mech3/CloudPuffs.cs`), tinted by the authored mask's colour, each card picking its puff, tilt, mirror and size off a hash of its own position. `FollowGraphicsMode` moves each kind onto the card shader for the standing mode, one compiled per text and kept, and writes its pool and cull margin again; `WarmOtherMode` compiles the other mode's ahead. Gating: `GameSession`/`WorldBuilder`/`WeatherRig`. Schema: [../formats/fogvol.md](../formats/fogvol.md). +## src/Effects/Ocean.cs +The Enhanced wave ocean on every chapter with a sea at y = 0 (`Ocean.Covers`, all but C4). A camera-centred polar +grid with a Gerstner swell in its vertex stage and drifting noise in its fragment normals replaces the sea-level base +sheet, which steps aside through `csky_ocean.gdshaderinc` only where the ocean draws; its colliders stay flat. It +draws a priority level below the lowest base sheet, one grid per zone-gate group; a spyglass disc +(`SceneBuilder.FlatSeaEye`) sees the flat sheet. Owns the grids, the sea's uniforms and up to 16 ship calm zones +(`OceanCalmZone.cs`, `OceanMovers.cs`), nearest the eye first; the swell, bent crests, noise detail, foam and coast +ramps are the shader text `OceanShader.cs` writes from the chapter's `SeaState.cs`, all on `csky_time`. `Apply` +takes a new sea live, recompiling only when the text changes. `GameSession.FollowOcean` builds and drops it. + +## src/Effects/OceanSeas.cs +The shipped per-chapter seas, `CSVM/data/ocean_seas.json`, read through `res://` at each sea chapter's build: one +object per chapter (`Chapters`, which `Ocean.Covers` reads) holding only the fields that differ from the defaults. +A missing entry or field takes the default; an unknown chapter or field, a non-number and a clamped value are each +a `world` warning. `WithEntry` rewrites one chapter's entry in place for the ocean lab's Save, keeping every other +key and its order, skipping comments as the read does, and refusing a file it cannot parse rather than replacing it; +`SourceTreePath` is null in an exported build. `OceanSeasTests`. Read `SeaState.cs` next. + +## src/Effects/OceanShader.cs +The wave ocean's shader text from one `SeaState`, with no engine object touched: the swell, detail and foam patch +tables, the bending field, the coast ramps and the open sea's tint, written as literals. At the defaults the text is +byte-identical to the ocean's tune (`OceanShaderTests` against hashes and a fixture), so nothing moves until a sea +is saved. Every swell omega, detail drift and patch drift is rounded to whole cycles per `csky_time` wrap at any +setting. Height, foam strength and roughness stay uniforms, set by `Ocean.cs`. + +## src/Effects/SeaState.cs +One chapter's sea as a record: every tunable of the wave ocean (swell, bending and detail, foam, coast and colour) +with the ocean's tune as its default, and `Fields` naming each one's key, label, group, range and slider step for the +file, the flag and the lab. `Clamped`, which every path into the shader takes, holds each value in range, sharpness +times height at the fold limit (at the base group gain, so a grouped crest can still fold), and each coast ramp at +least 4 m wide within the mask's 160 m reach. `Written` is the sea at the file's precision, rounded toward a bound +it would otherwise pass. `WithOverrides` reads `--debug-ocean`. `SeaStateTests`. Read `OceanShader.cs` next. + +## src/Effects/OceanCalmZone.cs +One ship's calm zone on the wave ocean: a box on the water along the hull's heading, grown over +world-space footprints (a mesh AABB under its global transform, or a point), with no engine object +touched. `Distance` and `Calm` are the shader's `ship_calm` in C#: no waves within a margin of the +box, full height a fade further out on a smoothstep. `OceanCalmZoneTests` hold the box and the fade. + +## src/Effects/OceanMask.cs +The wave ocean's shore mask at 8 m texels, baked once per built world (keyed on its `SceneBuilder`), reused by every +rebuild. Owns sea coverage, the distance to the nearest shore, surf or solid texel, the sheet's baked tint, each texel's +zone group where the sheet spans zone-gate layers, and the ramps: base-sheet triangles rising from sea level to under +`RampTop` (C2's harbour 1.03 m, C5's 3.03 m under the steinmann ship; C3's 13.17 m chute to its raised lake is no ramp). A ramp is +open sea the hide clears whole, and the grid rises with it to its quay. The walk also finds the wakes, tile size and +lowest priority; hulls (`OceanMovers.cs`) stay out. `Publish` hands mask, zones and rect to the `csky_ocean_*` globals +the hide reads (`SeaThreshold`); `Withdraw` restores the defaults. `--dump-ocean-mask=` writes the mask and the tint. +Read `OceanMaskRaster.cs` for the texels and `Ocean.cs` for the sampling. + +## src/Effects/OceanMaskRaster.cs +The mask bake's compute: world-space triangles in, the RG8 mask, RGB8 tint, R8 zone and R8 ramp lift bytes out, with no engine +object touched. Owns the triangle fill, the shore distance pass and the off-sheet tint, and runs +in row bands on the thread pool. A texel off the sheet takes its tinted neighbours' mean, so the filtered tint does +not lighten the sheet's edge. `OceanMask.cs` collects the triangles and uploads the bytes. `OceanMaskRasterTests` +hold the banded bytes to a plain single pass. + +## src/Effects/OceanMovers.cs +The rule for the boats an animation carries across the sea, with no engine object touched. A mover is the target +of a played OBJECT_MOTION_FROM_TO with a translate channel, an SI script or an OBJECT_TRANSLATE_STATE; reset +states, spins and ballistic debris are not. `GameSession.AnimatedMovers` asks `AnimRuntime.TargetsOf` for the +nodes `MovingEvents` moves, resolved by the dispatch's own rule. `OceanMask`'s walk judges each: a hull has its origin within 3 m of +sea level over the world's own sea-level water and meshes within 2 m of it; one hidden at the bake is judged by +`Ocean` once it shows. `Nearest` picks the zones when more hulls float than there are slots. `OceanMoversTests`. + ## src/Effects/Precipitation.cs Rain and snow from `weather.json`'s precipitation block (`WeatherState.PrecipData`): ONE MultiMesh whose shader derives each quad's position from a per-instance seed, `csky_time` and diff --git a/docs/architecture/Flight.md b/docs/architecture/Flight.md index 0de5f4138..d5d402000 100644 --- a/docs/architecture/Flight.md +++ b/docs/architecture/Flight.md @@ -19,10 +19,10 @@ behaviour: [../org/ordnanceTypes.md](../org/ordnanceTypes.md). Inspect with `--d ## src/Flight/Weapons/Loadout.cs Two layers over `CSVM/data/stock_loadouts.json`. `StockLoadouts.Load` parses the file into per-plane `LoadoutDef`s; `Loadout.Bind(def, builtPlane, WeaponDefs)` resolves each gun slot's markers to live -muzzle `Node3D`s and its caliber plus ammo to a `WeaponDef`, and each hardpoint to its `pylon`, +muzzle `Node3D`s and its caliber plus ammo, or its named `weapon`, to a `WeaponDef`, and each hardpoint to its `pylon`, yielding `GunGroup`s with their own ammo counters and `Hardpoint`s. Turret slots bind but stay inert. `Loadout.ForRig` synthesizes a lab loadout covering the airframe's whole rig rather than only what -stock names, and runs it through the same `Bind`, so there is exactly one bind path; `BindAi` (an AI def's `weapons`) and `BindWingman` (a wingman's pick, [../org/aiPilot/aiWeapons.md](../org/aiPilot/aiWeapons.md)) end in it too. `Hangs`, `WingPylons`, `WingCounts` and `PylonForCell` read a fit the other way, which pylons it carries, how many each wing hangs and which one a saved ordnance cell names, off the rig's odd-to-port split rather than a count heuristic, so the flight check, the ammo screen and a fresh hangar build all bound their cells alike. `ApplyRocketOverride` is the `--rocket=` hook, every pylon re-armed with one named weapon, which the human assembly and the weapon lab both call. Inspect with +stock names (its node-free `RigDef` keeps a slot's named `weapon`), and runs it through the same `Bind`, so there is exactly one bind path; `BindAi` (an AI def's `weapons`) and `BindWingman` (a wingman's pick, [../org/aiPilot/aiWeapons.md](../org/aiPilot/aiWeapons.md)) end in it too. `StockLoadouts.Overlay` lays the `Supplement` file over the committed one, a supplement plane replacing any committed def on its model. `Hangs`, `WingPylons`, `WingCounts` and `PylonForCell` read a fit the other way, which pylons it carries, how many each wing hangs and which one a saved ordnance cell names, off the rig's odd-to-port split rather than a count heuristic, so the flight check, the ammo screen and a fresh hangar build all bound their cells alike. `ApplyRocketOverride` is the `--rocket=` hook, every pylon re-armed with one named weapon, which the human assembly and the weapon lab both call. Inspect with `--dump-loadout`. Slot-to-firepoint binding: [../formats/markers.md](../formats/markers.md); schema: [../formats/loadouts.md](../formats/loadouts.md). Read `LoadoutChoice.cs` next. @@ -636,7 +636,8 @@ A mission's `targets.json` as one table: target key to its objective display key a nested `[parent, child]` entry keys `parent/child`, the same spelling `ObjectiveTarget` gives the script's directives, so the two tables meet on one string. `Load(mission, chapter)` walks the original's reader search path, and `ByNode` exposes the whole table for a consumer that wants the -starting flags rather than one key's labels. Schema: [../formats/missions.md](../formats/missions.md). +starting flags rather than one key's labels. `Objectives(keys)` is a table built in code, every key +flagged, for a stage that ships none. Schema: [../formats/missions.md](../formats/missions.md). ## src/Flight/Weapons/ObjectiveTarget.cs One argument of a target directive (`ADD_`/`REMOVE_OBJECTIVE_TARGET`, `ADD_`/`REMOVE_OTHER_TARGET`, @@ -741,13 +742,13 @@ aircraft and level otherwise. Also owns `RefWindow`, `RefRadius` and `DefaultOn` ## src/Flight/Camera/SpyglassView.cs The spyglass picture: a square `SubViewport` rendering the SHARED world through a `Camera3D` of its -own, one per pane, hung on `TargetHud` so it sits inside that pane's viewport. `Aim` points it -(`Spyglass.Pose`/`FovDeg`), sizes it to the disc's drawn diameter, borrows the pane camera's clip -planes and cull mask and starts it rendering; `Idle` stops it. The world is inherited rather than -owned, so the target is the one in play and wears the flown zone's fog. `DiscMask` departs from the -pane's view: the pilot's own aeroplane's layer (`UI.Boards.SplitScreen.OwnAirframeLayer`, stamped by -`Session/HumanFlightAdapter`) is dropped, and under Enhanced the sun is traded for `SpyglassSun.cs`. -`Census` is the `--perf` spyglass line. `TargetHud.DrawDisc` masks it to a circle. +own, one per pane, hung on `TargetHud` inside that pane's viewport. `Aim` points it, sizes it to the +disc's drawn diameter, borrows the pane camera's clip planes and cull mask and starts it rendering; +`Idle` stops it. The inherited world keeps the target in play and the flown zone's fog. `DiscMask` +drops the pilot's own aeroplane's layer (`UI.Boards.SplitScreen.OwnAirframeLayer`, stamped by +`Session/HumanFlightAdapter`); under Enhanced it trades the sun for `SpyglassSun.cs` and adds +`SplitScreen.FlatSeaLayer`, so the disc shows the flat sea, not the wave ocean (`SceneBuilder.FlatSeaEye`). +`Census` is the `--perf` spyglass line, read on the render thread. `TargetHud.DrawDisc` masks it round. ## src/Flight/Camera/SpyglassSun.cs The spyglass discs' own sun under Enhanced: a shadowless `DirectionalLight3D` on diff --git a/docs/architecture/Launch.md b/docs/architecture/Launch.md index e57e2699e..abbb56640 100644 --- a/docs/architecture/Launch.md +++ b/docs/architecture/Launch.md @@ -7,7 +7,7 @@ One `## src/...` entry per module, body at most 8 lines, 12 for the highest-traf Traps do not live here; the rule is in `docs/architecture.md`. ## src/Launch/Launcher.cs -Main.tscn's root and the process bootstrap: CLI parse into `_cli`/`_spec`, data-root precedence, the +Main.tscn's root and the process bootstrap: CLI parse into `_cli`/`_spec`, data-root precedence and the `--synthetic-data` swap (`Tooling/SyntheticData.cs`), the editor check that gives an export its `logs\` and audible volume default, the developer gain on bus 0 with the saved mix under it, at startup and on an Options apply (`Utils/MasterVolume.cs` resolves the first, `Utils/AudioMix.cs` writes the second), the `--dump-*`/`--run-tests` early quits and the `--extract` run (`Extraction/ExtractionRun.cs` on a worker thread), and what outlives a session (camera, sun, audio, music, the perf and hitch instruments, the stick pump `Sticks/StickPump.cs` started after the pad roster is logged, the typed-character feed `UI/Boards/TypedText.cs` its `_Input` fills with every key event's character and paste chord, and the one `ChapterCinema` and `ClosingCinema` the campaign's doors play through). It owns the menu as one `MenuHost` built on the first show, the presentation resolution, the only options write (an apply from the in-flight `UI/Screens/PausePreferences.cs` leaf takes that same route, without the presentation reselect a menu-side apply ends on), @@ -20,7 +20,7 @@ extraction or a stale stamp it shows `UI/Screens/NoGameDataScreen.cs` before any The per-launch orchestrator: `Launcher` constructs it from `(SessionSpec, LauncherContext)` and `StartSession` runs ordered build steps over one `BuildState.cs`, the shared input of every step. It owns the session clock, the world root, panes and seats, resource lifetimes and the per-frame tick order across the runtimes (`_Process`, both clock adapters and the phase map it hands `SessionSimulation`). It composes the steps that each write one subsystem: `SkyStage.cs` (weather, cloud field and banks, decks, flare), `InspectionLabs.cs` (the parked-plane view, freecam, anim lab, selection labs, debug overlays), `SessionProbes.cs` (the scripted probes and build-time forces), `SessionBoards.cs` (pause, results boards, photo mode), `ProjectileStage.cs` (the shared pool and its sinks), `SessionVoices.cs` (the radio and combat voice), `KillLines.cs`, `OppositionStage.cs` (the `--ai` squadrons, zeppelins, generators, emplacements) and `ObjectiveReadouts.cs`, beside `FlightRoster` for aircraft, `WorldSession` for the world and `WorldEffectsFactory` for effects. The mode tails sit on their directors, `InstantActionDirector`, `CampaignDirector` and, for a Dogfight, `Session/World/VersusDirector.cs`, and `BuildFlightRigs` calls the directors' phases between the steps in the order the build needs. `_rigs` is the pane list every camera-anchored system reads; `_seatRigs` is the whole field, a network match's guests and any match's bots included, and sizes the roster, the spawn walk, the versus board, the human-aircraft step and a campaign's human field (`HumanField`), so a guest flown elsewhere counts at its interpolated pose, and a co-op campaign of more than one seat starts on `StartGrid` whatever its local pane count. A stunt run is a `StuntRace` with a second seat or as a lobby Stunt Race (`IsRace`), on the wire timing this machine's seats and replicating the host's board; `LeaveFlight`, every board's and the pause sheet's exit, tells a network race first, and a guest's `Rerun` waits for the host's rerun call, which opens its window through `RerunRace`. -The pane count also reaches the Launcher's sun: building the rigs re-resolves the shadow level for that count (`ShadowQualitySetting.ResolveForPanes`), and the exit puts one pane back. +The pane count also reaches the Launcher's sun: building the rigs re-resolves the shadow level for that count (`ShadowQualitySetting.ResolveForPanes`), and the exit puts one pane back. The wave ocean follows the graphics mode and `Utils/WaterQualitySetting.cs` (`FollowWaterQuality`), built and dropped live, drawing the chapter's sea from `Effects/OceanSeas.cs` under any `--debug-ocean` overrides and the ocean lab's edits. The wire and everything wired onto it is `SessionNet.cs`, opened in the constructor so a host can answer a join before its world stands; the session calls its join, seat build, clock, start gate and link steps at their build points and steps it before each simulation step, so an arrival is applied on the step after it landed. After the synchronous build it constructs one `SessionSimulation`, which owns the step order. `AllAircraft` combines the roster's AI view with the ordered rig controllers, and `StepOwedLoad` drains the wave aeroplanes `OppositionStage.OrderWaveAirframes` put behind the load screen. Exit frees the session subtree atomically and releases only the non-node resources it owns; the prohibitions that keep these rules true sit on the members they bind. Read `SessionSimulation.cs` next. @@ -35,7 +35,7 @@ The sky step of a session build and what it leaves standing: the mission's `Sess The session build's scripted probes and build-time forces: `--damage-test` and `--effects-test` over the chapter world just built, `--dump-tilegrid` once the map edge exists and the parked `--weapon-test` bench, each of which reports through `Tooling/ProbeRunner.cs` and ends the process, and `--destroy=` and `--debug-objective=`, which change the built world and let the session fly on. A probe that ran answers true and the build stops at that step. Read `Tooling/ProbeRunner.cs` next. ## src/Launch/InspectionLabs.cs -The inspection side of a session build, each step joining its nodes at the session's own build point: the parked-plane view (`--viewer` or a bare `--plane=`) with its damage, livery, mesh and marker labs; the freecam and the anim lab's quiet stage; the shared world selection with its node and world damage labs; player 1's flight damage and weapon labs; the overlays every observing mode carries (colliders, classes, the map edge's tile grid, node labels); and the flight's debug keys (F13's patrol nets, F15's targeting lines, F16's markers, F17's kill). `Spectator` is the freecam or anim-lab camera a `--destroy=` frames. Nothing here runs per frame. Read `UI/Screens/SelectionService.cs` next. +The inspection side of a session build, each step joining its nodes at the session's own build point: the parked-plane view (`--viewer` or a bare `--plane=`) with its damage, livery, mesh and marker labs; the freecam and the anim lab's quiet stage; the shared world selection with its node and world damage labs; the ocean lab, freecam only; player 1's flight damage and weapon labs; the overlays every observing mode carries (colliders, classes, the map edge's tile grid, node labels); and the flight's debug keys (F13's patrol nets, F15's targeting lines, F16's markers, F17's kill). `Spectator` is the freecam or anim-lab camera a `--destroy=` frames. Nothing here runs per frame. Read `UI/Screens/SelectionService.cs` next. ## src/Launch/SessionBoards.cs The whole-window boards over one flight: the pause board (the Original presentation's sheet for a campaign mission, a Dogfight or an Instant Action sortie, the Built-in `PauseBoard` otherwise) under the launcher's `PausePreferences` leaf, the results boards (the race, the Original presentation's `OriginalRaceBoard` where the install carries the lobby art and string table and the Built-in `StuntRaceBoard` otherwise; the dogfight, the Instant Action wrap-up, each pane's solo stunt scoreboard), one `MenuInput` per local player, and photo mode, which suspends whichever board is up and restores it on the way out. Each board's Restart is the mode's, handed in (the race board's is the session's rerun, so in Instant Action a new window rebuilds the mission; a network race's board names its exit Lobby on the host and Leave on a guest, whose board withholds Restart), and the Instant Action and solo stunt boards reach their directors through the `BuildIaWrapupBoard` and `BuildSoloStuntBoard` seams. Read `UI/Screens/ResultsBoard.cs` next. @@ -56,7 +56,7 @@ The kill and crash lines in each pane's message stack: a downed aircraft posts o The opposition outside the directors' own rosters, in four steps the session calls at their build points: `BuildSquadrons` (the `--ai` entries, on a net, on the empty stage's ring, or ahead of P1), `BuildZeppelins` (the mission's hulls with their damage, cannons and target sub-parts, or the `--zep=` graft), `BuildGenerators` (the egen runtime, its launches through the roster and `OrderWaveAirframes` for the load screen) and `PlaceEmplacements` (the world AA guns with the Instant Action zeppelin arm before `--wake-turrets`). Each returns the runtime it built for the session to step. Read `Session/Roster/AiGeneratorRuntime.cs` next. ## src/Launch/ObjectiveReadouts.cs -A flown mission's objective readouts once the graph is armed: each pane's objectives readout and mission-end fade and the clock's expiry notices on a campaign, and the objective sites on the player's target cycles. A mode without a director takes the same site feed off the mission's own targets.zrd when `BindsMissionTargetTable` says so. Read `Session/Objectives/ObjectiveSites.cs` next. +A flown mission's objective readouts once the graph is armed: each pane's objectives readout and mission-end fade and the clock's expiry notices on a campaign, and the objective sites on the player's target cycles. A mode without a director takes the same site feed off the mission's own targets.zrd when `BindsMissionTargetTable` says so. On the empty stage a `--ctf` match's site feed flags the arena's own flag markers (`EmptyStage.ArenaTargets`, `MissionTargets.Objectives`), since the stage has no `targets.zrd`. Read `Session/Objectives/ObjectiveSites.cs` next. ## src/Launch/TuningWarmup.cs The startup pass that fills `Config`'s key registry before `Config.ReportOrphans` and @@ -104,8 +104,8 @@ doors; off writes a fresh object's defaults back. `Switch` is the whole live mod for a network session: shaders, sun, Environment, clutter fade, display quality, then `GameSession.ApplyGraphicsMode`, which opens with `FollowAlphaDepth`; its log line carries `Utils/SwitchProfile.cs`'s steps. `HasSwitched` lets a later load warm the other mode's shaders, -`WarmAdvancedVariants` their TAA variants through one hidden frame. `ApplyViewDistance` is the live -View Distance; `Utils/SunShadow.cs` carries the sun to the cockpit; `SwitchCover.cs` draws a switch over. +`WarmAdvancedVariants` their TAA variants through one hidden frame. `ApplyViewDistance` and +`ApplyWaterQuality` are the live View Distance and Water Quality; `SwitchCover.cs` draws a switch over. ## src/Launch/SwitchCover.cs A live graphics switch over a flying world, drawn over so the stall does not read as a crash. The diff --git a/docs/architecture/Mech3.md b/docs/architecture/Mech3.md index 74d48e758..748abaab0 100644 --- a/docs/architecture/Mech3.md +++ b/docs/architecture/Mech3.md @@ -268,7 +268,8 @@ read. Schema, offsets, the per-chapter census and the keying rules: [../formats/ ## src/Mech3/Zrdr.cs Zrdr extraction reader (zip or unpacked dir): `LoadFile`, `LoadFileOrEmpty`, content-sniffing `LoadMatchingFiles`, name-predicate `LoadFilesNamed` (for families with nothing to sniff, e.g. the -`ne0*` nets), and `ZrdrDict`, the key/[values…] view over a reader's alternating list. +`ne0*` nets), `HasFile` (whether an entry is there, under either stored name), and `ZrdrDict`, the +key/[values…] view over a reader's alternating list. ## src/Mech3/GamePath.cs Splits a path the game's data names (`..\data\c1\m02\zrdr\cutscenes\cabpickup.zrd`) on `\` and `/` @@ -622,12 +623,12 @@ edits the prototype material the rack copies share. The fade shader itself is ## src/Mech3/Anim/NameResolver.cs Name to node resolution as one public module, generic over the node type (`NameResolver`): the index, the wildcard `Matcher`, the memoized `FindAll` (an answer extends over appended rows -rather than being voided by them), the scoped tier chain -(`Resolve`/`ResolveScoped`), the symbol authority (`SymbolClaims`/`NarrowToSymbolRoot`), `Anchors` +rather than being voided by them), the scoped tier chain (`Resolve`/`ResolveScoped`, and `PeekScoped` for read-only queries), the symbol authority (`SymbolClaims`/`NarrowToSymbolRoot`), `Anchors` (NAME match, symbol narrowing, root lift) and the bind census. Node identity is constructor-supplied, never the node type's inherited `Equals`; `DropFreed` retires the rows naming a freed node and `DropNodes` those naming a live subtree a second staging replaces, since a name resolves to whichever claimant was indexed first. `AdmissibleStaging` filters every tier, the owner's verdict on one pooled copy, and `RefusesGlobalTier` withholds the last tier from a plain name written by a definition -the world holds several instances of. `Claimed` hears each node a `FindAll` answer or `Anchors` first hands out. Decode: [../org/sequences.md](../org/sequences.md). +the world holds several instances of. `Claimed` hears each node a `FindAll` answer or `Anchors` first hands out. Inside `Peek` (with `PeekScoped` and +`PeekAnchors`) a query claims nothing and writes no memo, so a later real query still claims what it finds. Decode: [../org/sequences.md](../org/sequences.md). ## src/Mech3/Anim/CutsceneFastForward.cs The rate one cutscene episode's own definitions run at while the player holds a key through a scene @@ -813,8 +814,9 @@ Callers are serial by construction. An instance lives as long as its holder. ## src/Mech3/EmptyStage.cs The `--stage=empty` test stage: a flat collidable 20 km ground plane under a 100 m grid, standing in for a chapter world so flight and ballistics runs boot in about 2 s with nothing else in the frame. -It also carries the one patrol net a session on this stage has: `PatrolNet`, a closed eight-node ring -of 1000 m about the grid origin at the spawn altitude, authoring 2500/1500/700 m volumes so a vehicle -put on it runs on a net's gates rather than the mode machine's defaults. `--ai=:grid` and -`--zep=...:net=grid` reach it through `ResolveNet`, which answers before the chapter `neindex` lookup -on a name no shipped index carries. Built in code, like the grid texture: no chapter assets are here. +Its patrol net, `PatrolNet`, is a closed eight-node 1000 m ring at the spawn altitude with its own +2500/1500/700 m volumes, which `--ai=:grid` and `--zep=...:net=grid` reach through `ResolveNet`. +A `--vs` match walks `SpawnRing` in place of `net.zrd`, sixteen entries 600 m out and aimed in, and a +team match `SpawnTable`, that ring plus four team blocks at the teams' bases. `Build(arena: true)` +stands `ArenaNodes`, two bases' `ctf_n`, `cs_flag_n`, `cs_flg_lightn` and `rearm_node_n`, which +`ArenaNode` finds by name; `ArenaTargets` lists the flag markers. All of it is built in code. diff --git a/docs/architecture/Session.md b/docs/architecture/Session.md index 7e560e0c4..83bf10b4b 100644 --- a/docs/architecture/Session.md +++ b/docs/architecture/Session.md @@ -34,14 +34,14 @@ every human (a def read off a file) or is only player 1's pick (the launchscreen Pure over the spec. The built aircraft's display name is `Flight/Airframe/PlaneRoster.cs`'s. ## src/Session/Roster/SpawnPicker.cs -Resolves each player's flight spawn: `LoadSpawnList` (which list the session walks, the mission's -`ia.json` scenario or a Dogfight launch's `net.zrd` block, the whole table when `SeatTeams` names a team, which `PlanTeams` walks by team block), `ChooseSpawnBase` (the shared -`--spawn=`-or-random list index), `ChooseSpawn` (a player's position and look-at from that list, -`objectives.json`'s `PLAYER_INIT`, or the `--spawn-at=` debug override), `StartState` (the field's -throttle and speed) and `LogSpawn`. Constructed once per session build. Also the plain -`IFlightStarts`: `ChooseStarts` loops its own `ChooseSpawn`, the placement every session flies -except a splitscreen race or a co-op campaign mission. `StartGrid` and `SharedSpawnStarts` take -their anchor from here, so this type owns it. Data: [spawns](../formats/spawns.md), [net](../formats/net-spawns.md). +Resolves each player's flight spawn: `LoadSpawnList` (which list the session walks: the mission's +`ia.json` scenario, a Dogfight's `net.zrd` block, the whole table when `SeatTeams` names a team, +which `PlanTeams` walks by team block, or on `--stage=empty` the `SpawnRing` or whole `SpawnTable`), +`ChooseSpawnBase` (the shared `--spawn=`-or-random index), `ChooseSpawn` (a player's position and +look-at from that list, `PLAYER_INIT`, or the `--pos=` override), `StartState` (the field's throttle +and speed) and `LogSpawn`. Built once per session. Also the plain `IFlightStarts`: `ChooseStarts` +loops `ChooseSpawn` for every session but a splitscreen race or a co-op campaign mission, and +`StartGrid` and `SharedSpawnStarts` take their anchor from here, so this type owns it. Data: [spawns](../formats/spawns.md), [net](../formats/net-spawns.md). ## src/Session/Roster/IFlightStarts.cs Where every pilot in a session starts: `ChooseStarts(spawns, missionZrdrPath, spawnBase, @@ -127,7 +127,7 @@ their own record authors: `CollectFlagged` runs once per `TargetFlag`, over `tar `objective` half (the Enemy cycle) and then its `other_target` half (the Non-Aircraft cycle), the curated list admitting a mission's chosen structures and no other destructible. A campaign director's script edits both with `ADD_`/`REMOVE_`; the director-free constructor is what Instant Action and the multiplayer modes -take, their table unedited. World SITES only, one `Flight/Weapons/ObjectiveSite.cs` per `ObjectiveTarget.Key`, re-read every frame so +take, their table unedited, and the empty stage's Capture the Flag arena passes its own node finder. World SITES only, one `Flight/Weapons/ObjectiveSite.cs` per `ObjectiveTarget.Key`, re-read every frame so a site tracks a moving node and reads `Live` off its `DestructibleRegistry` state and whether its node is switched on; a roster block that flags itself rides its own aeroplane. `Sides` lets a team mode label, place or hide a key. Bound by `GameSession`; [../org/targeting.md](../org/targeting.md). @@ -212,11 +212,11 @@ seat tick, and apply through `ApplyReplicatedHealth`. `FollowVoice` relays each ## src/Session/World/VersusDirector.cs The engine side of one Dogfight (`--vs`), split screen or over the wire, the sibling of `InstantActionDirector` and `CampaignDirector`: `TryCreate` builds the `VersusMatch`, teamed off the lobby's seats, ahead of the roster, and `Wire` feeds it every seat's Downed report (reported to the host on the wire; scored here in a local match, which posts the death lines too), sets each pilot's crash-cam respawn (a bot seat's never waits for Fire Guns, whatever Auto Respawn says) and builds the `VersusSpawnRotation` of the host or the local machine, which relaxes its one-living-seat-per-point rule for a field larger than the table rather than failing. Having a wire and having a seat roster are separate questions: a local match with bots holds a roster and no wire, and decides its returns, scores and rematch itself. `WireSpawns` puts placement on the wire under one rule: the OPENING spawn is the shared seed's walk over the mission table and crosses no wire, while every later return is GRANTED by the host's single rotation, a table entry every peer applies through the call the owner would have made (`SpawnsTaken`, `SpawnEntries`). `WireMatchState` makes the host the only writer of the match: the clock, both limits and the ending go out as one reliable message, change-driven plus a `MatchStateCadence` tick that carries the host clock into every guest's `NetClockSlew`, and a guest hands its match over, its rematch the host's (`RematchIsTheHosts`). `ScoreDeath` is the host's one scoring site; it sends the scores, then the death notice every machine posts once, and the ending only after the scores that settled the round. The scoreboard itself is never sent. -`StepMatch` advances the clock, holds a pilot out of lives spectating (`VersusMatch.OutOfLives`) and posts its lives line; the session steps the flags and the rearm bases ahead of it. `WireFlags`, `WireZeppelinVersus` and `WireRearmBases` open `FlagRuntime.cs`, `ZeppelinVersusRuntime.cs` (whose return is a `SpawnAtMessage` and whose Restart leaves for the lobby rather than rerunning on burnt hulls) and `RearmRuntime.cs`. Decode: [../org/multiplayer-scoring.md](../org/multiplayer-scoring.md). +`StepMatch` advances the clock, holds a pilot out of lives spectating (`VersusMatch.OutOfLives`) and posts its lives line; the session steps the flags and the rearm bases ahead of it. `WireFlags`, `WireZeppelinVersus` and `WireRearmBases` open `FlagRuntime.cs`, `ZeppelinVersusRuntime.cs` (whose return is a `SpawnAtMessage` and whose Restart leaves for the lobby rather than rerunning on burnt hulls) and `RearmRuntime.cs`, whose rearm nodes the world or the empty stage's arena holds. Decode: [../org/multiplayer-scoring.md](../org/multiplayer-scoring.md). ## src/Session/World/FlagRuntime.cs Capture the Flag in a network match, built by `VersusDirector.WireFlags` for a `--ctf` launch: one -`FlagMatch` flag per lobby team whose `cs_flag_n` the mission world holds. Each machine checks its +`FlagMatch` flag per lobby team whose `cs_flag_n` the mission world, or the empty stage's arena, holds. Each machine checks its own seats and asks the host (`FlagRequestMessage`); the host decides, scores through `VersusMatch.AddScore` and sends its `FlagTableMessage`. Every machine moves the props from the changes, hangs the carried flag under the holder's `cf_light`, speaks the `snd_CTF*` lines and posts @@ -236,7 +236,7 @@ base's. Decode: [../org/multiplayer-zvz.md](../org/multiplayer-zvz.md). ## src/Session/World/RearmRuntime.cs The multiplayer rearm bases in any Dogfight, built by `VersusDirector.WireRearmBases`: the world's -`rearm_node_n` serving lobby team `n`, or in Zeppelin vs Zeppelin each hull's `zep_rearm_node_n` +`rearm_node_n` (or the empty stage arena's) serving lobby team `n`, or in Zeppelin vs Zeppelin each hull's `zep_rearm_node_n` serving its side while the hull lives. Each machine steps only the seats it flies through `RearmBases`, and on entry calls `FlightController.Rearm` and posts "Rearmed!" in the seat's own pane. The restored ledger reaches every other machine in the next `0x40` damage report, where a diff --git a/docs/architecture/Testing.md b/docs/architecture/Testing.md index a4045e56a..9f25e14d5 100644 --- a/docs/architecture/Testing.md +++ b/docs/architecture/Testing.md @@ -19,11 +19,11 @@ instead; read `TestHarness.cs` for that half. `--run-tests[=filter]`: the suite registry, `TestContext` (assert verbs, resolved data paths, a scene-tree host, `SyncPhysics` for the space a one-frame run leaves behind, and the `WithWorld` chapter-world builder over `WorldSession`), the PASS/FAIL/SKIP table, `test-report.json` in -`TestContext.ScratchDir`, and the process exit code. `Select` is the pure selector over the flag's -value; `SuiteShards` handles the one term that divides rather than selects. The world cache and its -eviction, the mission-override and private-world forms, the shared `DecodeCache`, the per-build -`StartupProfile`, the between-suite queued-free flush and the engine-error allowlist carry their -own rules at their members. Read `SuiteCatalog.cs` for registration, `PhaseAttribution.cs` for time. +`TestContext.ScratchDir` (its `syntheticData` names a synthetic data root), and the exit code. +Its input gates SKIP a suite and name what it lacks: `RequireData` (a file) and `RequireZrdrEntry` +(a reader file in a zrdr ZIP or folder) on every tree, `RequireTexture` and `RequirePlane` (a shipped name) on the synthetic one. +`Select` is the pure flag selector, `SkipFailures` the SKIPs a tier makes FAILs and `SuiteShards` the shard term. The world +cache and its eviction, the mission-override and private-world forms, `DecodeCache`, `StartupProfile`, the queued-free flush and the engine-error allowlist carry their rules at their members. Read `SuiteCatalog.cs` for registration, `PhaseAttribution.cs` for time. ## src/Testing/FinalizerGate.cs The `--debug-finalizers` instrument: `TestHarness.Run` wraps each suite in one gate, which parks the @@ -83,10 +83,11 @@ emitter's own modes are. Neither covers the other's job. The registry of the in-engine assertion suites, discovered from the `[Suite("name", "what")]` attribute each body carries in the `*Suites.cs` modules; the catalog keeps no per-suite table, so adding a suite means adding one marked body in one of those modules and nothing else. `QuickTier` -is the checked-in membership of `--run-tests=tier:quick`, resolved through `Tier(name)`. The -determinism rule behind the alphabetical registry order, and why a malformed declaration throws -rather than being skipped, are stated at the members themselves. Read `SuiteShards.cs` for what -depends on that order. +and `CiTier` are the memberships of `tier:quick` and `tier:ci`; `Tier(name)` resolves each into a +`SuiteTier`, whose `SkipFails` (the ci tier's alone) makes a member's SKIP fail the run. The +determinism rule behind the alphabetical order, and why a malformed declaration throws rather +than being skipped, are stated at the members. Read `SuiteShards.cs` for what depends on that +order, and `docs/tooling.md` for each tier's selection rule. ## src/Testing/*Suites.cs The in-engine scenario bodies, one module per domain: the emitter model, combat and ordnance, @@ -104,7 +105,9 @@ an all-purpose helper. Per-suite traps live as comments on the suites themselves ## src/Testing/SuiteConstants.cs The shared golden inputs used by more than one scenario module: airframe and weapon counts, puffer -timing, the destructible census, texture samples, and the ordnance-burst step and slack. +timing, the destructible census, texture samples, and the ordnance-burst step and slack. It also +reads a `player.json` float back raw (`PlayerGlobal`), so a check can hold a typed field to the +record it came from on any data tree. ## src/Testing/BurstTimeline.cs The three value types describing an authored ordnance-burst timeline and its observed dispatches. @@ -114,6 +117,10 @@ Builds a test pane camera at a supplied world position for suites that exercise ## src/Testing/EffectStageSuiteHelper.cs Builds and frees a production-shaped, pooled effect-template stage for mesh-visibility suites. +`WithAnimSource` hands an effect suite its anim program, template gamez and scene builder, and +`WithAnimWorld` a destructible suite its world root and bound runtime. On an extraction both are the +chapter world; under `--synthetic-data` they are the `effects` family's invented records +(`Tooling/SyntheticEffects.cs`), the world a private one of its destructible roots. ## src/Testing/BotSuiteHelper.cs The readings the bot suites share: lifting a pilot clear of the ground, the spawn-table entry a diff --git a/docs/architecture/Tooling.md b/docs/architecture/Tooling.md index 9916a10bc..5d4249adc 100644 --- a/docs/architecture/Tooling.md +++ b/docs/architecture/Tooling.md @@ -1,6 +1,6 @@ # Tooling -The runtime tooling the game and the in-engine harness share: the `--dump-*` probes and their wrappers, the `--screenshot`/`--shots` capture, the golden-image hash, and the glTF export. The game and `CSVM.Testing` both depend on this namespace; the one call from here into the harness is `--run-tests`' dispatch in `ProbeRunner.cs`. +The runtime tooling the game and the in-engine harness share: the `--dump-*` probes and their wrappers, the `--screenshot`/`--shots` capture, the golden-image hash, the glTF export, and the `--synthetic-data` tree. The game and `CSVM.Testing` both depend on this namespace; the one call from here into the harness is `--run-tests`' dispatch in `ProbeRunner.cs`. One `## src/...` entry per module, body at most 8 lines, 12 for the highest-traffic modules. @@ -67,3 +67,67 @@ this data exports inside out). Format is extension-driven (`.glb` default). `Exp subtrees under one root at their world transforms, dropping any an ancestor in the list carries. `ExportToExports`/`ExportSetToExports` name a timestamped `Exports/` GLB. The viewer's `--export-gltf=` one-shot and F10, and NodeLab's selection and export set actions, share that writer. + +## src/Tooling/SyntheticData.cs +`--synthetic-data`'s tree: an `extracted/` of invented records and code-generated files written into +`.scratch/synthetic-data//`, which `Launcher._Ready` then reads as the data root. Engine-free, +so `CSVM.Tests/SyntheticDataTests.cs` builds the same tree. The records are `CSVM.Tests/fixtures/` +files read from the checkout, so an export has none and refuses the switch. A record family is one `SyntheticFamily` in `Families` (the Original shell's, which reads UI types, joins in `UI/Menu/Original/SyntheticShell.cs`'s `TreeFamilies`, the list `Build` is handed): its writer copies records with `SyntheticTree.CopyFixture` and +writes generated bytes with `WriteBytes`, under its own folder. The stamp carries `schema` and a +`synthetic` field, which `Marks` reads for the log and `test-report.json`'s `syntheticData`. +Read `SyntheticTextures.cs`, `SyntheticPlane.cs`, `SyntheticSounds.cs`, `SyntheticMission.cs` and `SyntheticEffects.cs` for the families, `Extraction/PngWriter.cs` and `WavWriter.cs` for the encoders. + +## src/Tooling/SyntheticTextures.cs +The synthetic tree's C1 texture archive, `extracted/C1/texture/`: the hand-authored +`fixtures/synthetic/C1/texture/manifest.json` copied as it stands, and one checker PNG per +`texture_infos` entry at that entry's size, so a texture is added by adding a manifest entry. Every +name is `probe_*` except `smoke101`..`103`, the exhaust trail's pool, which `ExhaustSmoke` names in +code. The shape is what `Mech3/TextureArchive.cs` reads, from the header fields in +[../org/textures.md](../org/textures.md). + +## src/Tooling/SyntheticPlane.cs +The synthetic tree's stand-in aircraft, every record invented: `probe_plane`, the default, and nine under +shipped airframe names the suites fly (`player_pfighter`, the lobby door's starter, the custom-plane +pair and six more), each carrying what the code keys on (`is_autogyro`, a turret mount). `plane` writes +`planes/` (`nodes.json`, `materials.json`, and a `models.json` generated from `boxes.json`, one +outward-wound box per entry) and `vehicle.json`, `engines.json`, `player.json` and `maneuvers.json`. +`armament` writes the weapons, the `ai.json` turret table, `shakes.json` and `messages.json`. `Launcher` +sets the fits as `StockLoadouts.Supplement` and the probe as `SessionSpec.DefaultPlane` under the switch. +Shapes: [gamez](../formats/gamez.md), [markers](../formats/markers.md), [vehicle](../formats/vehicle.md), [weapons](../formats/weapons.md), [turrets](../formats/turrets.md). + +## src/Tooling/SyntheticImages.cs +The synthetic tree's JPEG and TGA writers, for menu art a layout or a script names by those +extensions, since a loader picks its decoder by the name: a baseline greyscale JPEG whose 8x8 +blocks are flat, so each is one DC term, and an uncompressed top-down 24-bit TGA. Both draw a +checker shaded from the name. PNGs stay with `Extraction/PngWriter.cs`. + +## src/Tooling/SyntheticSounds.cs +The synthetic tree's `sounds` family: `zrdr/sounds.json`, the fixture `SETS` and `SOUND_GROUPS` +copied as they stand, and `soundsh/`, the unpacked sibling of `soundsh.zip`, holding one generated +mono MS ADPCM WAV at 22050 Hz per entry of `fixtures/synthetic/soundsh/manifest.json` (a length and +a tone, or seeded noise). The build throws when the manifest and the `SETS` WAV names differ, so a +sound is added by a definition and a manifest entry together. Every definition is `snd_probe_*` except the music cues `MusicPlayer` looks up, the propeller start the spawn suite reads, and the voice sets, named `snd_id_*` as `CombatVoice` builds them for the ids `PilotVoices` seats. +The `voice` family copies `zrdr/voice.json`, the accent table dealing those ids. Shapes: +[../formats/sounds.md](../formats/sounds.md), [../formats/combat-voice.md](../formats/combat-voice.md). + +## src/Tooling/SyntheticMission.cs +The synthetic tree's `mission` family, one invented mission scope `extracted/C1/PROBE1/zrdr/`: a +`weather.json` whose two zones author different sun bearings under a cloud band inside the flight +envelope, and a `net.json` of one 16-entry free-for-all block. The weather, sun and net-table suites +read it where an install reads a shipped mission. No shipped mission name enters the tree, so a suite +gated on one still skips. Shapes: [../formats/weather.md](../formats/weather.md), +[../formats/net-spawns.md](../formats/net-spawns.md). + +## src/Tooling/SyntheticEffects.cs +The synthetic tree's `effects` family. Under `extracted/probe_effects/` it writes `gamez/`, one +legacy-shape node per entry of `fixtures/synthetic/probe_effects/templates.json` with a box per meshed +node, and `cam_anim/`, the compiled archive shape (`metadata.json` and one file per definition) split +out of `cam_anim.json`. It copies the reader destructibles, `flame_ball.json` and `pufftrails.json` +into `zrdr/`. No session loader reads `probe_effects/`; `Testing/EffectStageSuiteHelper.cs` does, +under the switch. Names the engine or a suite looks up are kept over invented content. + +## src/Tooling/WavWriter.cs +Encodes mono 16-bit samples as a RIFF/WAVE file, plain PCM or MS ADPCM (the seven standard +coefficient pairs, 256-byte blocks, a `fact` chunk with the true length), the layout +`Mech3/WavFile.cs` decodes. Each ADPCM block is encoded under every coefficient pair and the closest +kept. Engine-free, so `CSVM.Tests/SyntheticSoundsTests.cs` round-trips it through the reader. diff --git a/docs/architecture/UI.md b/docs/architecture/UI.md index c51bb3f4d..3d049935c 100644 --- a/docs/architecture/UI.md +++ b/docs/architecture/UI.md @@ -579,7 +579,7 @@ stand side by side from 2:1 out (`SideBySide`); three and four are the 2x2 grid. 3D audio listener**, or nothing positional is audible: Godot takes the per-channel maximum over listener-enabled viewports. `Fill(true)` gives pane 1 the window for a cutscene; `NoteSkip` names a skipper. Per seat, `OwnAirframeLayer` is dropped only by that pilot's disc and `FirstPersonLayer` only -by that pilot's pane (`SeatAirframe` writes both); no pane draws `SpyglassSunLayer`, the disc no `SunLayer`. Every camera draws `EveryCameraLayer`, the race ghost owner a seat flown elsewhere names. +by that pilot's pane (`SeatAirframe` writes both); no pane draws `SpyglassSunLayer` or carries `FlatSeaLayer` (no geometry, the disc's flat-sea marker), the disc no `SunLayer`. Every camera draws `EveryCameraLayer`, the race ghost owner a seat flown elsewhere names. ## src/UI/Boards/ScreenFlash.cs The full-screen colour wash, two channels over one hidden `ColorRect` per rendered view. The ramp @@ -844,6 +844,15 @@ transform, and puts the count on the selection's breadcrumb through `HudLine`, b gathered whether or not the panel was ever opened. A member freed under it (a destructible swapping to its wreck) leaves on its own. Nothing is drawn until the first node joins. +## src/UI/Labs/OceanLab.cs +The ocean lab (Shift+F1), built by `InspectionLabs.BuildOceanLab` in `--freecam` on a sea chapter and nowhere else: +a panel down the right edge with one slider per `Effects/SeaState.cs` field in its four groups, each with its value +and default. An edit reaches the session on release or a quarter second after a click, and the session hands it to +the standing ocean and to any ocean a switch rebuilds. Reset to defaults, Revert to saved, and Save, which writes +this chapter's differing fields through `Effects/OceanSeas.cs` into the source tree's file and is off in an +exported build. The sliders take no keyboard focus and the freecam looks on the right button. `--debug-ocean` is +the scripted twin, checked at launch by `SessionSpec`. + ## src/UI/Labs/WorldDamageLab.cs The world damage lab (key F19) in `--freecam` and `--anim-lab`: the destructible pools of whatever the selection holds, each with live HP, and a slider with kill and reset on the one a weapon hit @@ -1214,7 +1223,7 @@ The VIDEO page, the Game Options table's shape over the authored Video rows: the Anti-aliasing over `Utils/OptionsStore.cs`'s `DisplayWords`, and Enhanced Graphics on the Shadows checkbox whose gate it owns. Render Scale stands on the Objects Detail line and Anti-aliasing on the Lighting Quality line (whose `VP_D_DLight` authors five items), picking FSR 2.2 narrowing the scale list to 50..100 on the spot; Shadow Quality (`Utils/ShadowQualitySetting.cs`) stands on the Texture Quality line, dead while Enhanced Graphics is -clear. The Graphics row's title and description are the page's own. It holds the display settings and the view distance it carries unshown. +clear. The Graphics row's title and description are the page's own. It holds the display settings and the view distance and water quality it carries unshown. ## src/UI/Menu/Original/OriginalControlsPage.cs The CONTROLS page over the shared `ControlsFeature`: the seat chooser on the Controller Type row, the authored Mouse Sensitivity slider over @@ -1399,6 +1408,15 @@ caller to log once. `ArtPath` and `RelativeArtPath` are where a layout art name `RofTree`'s upper case, the presentation's size read going through the first; `IsMovie` puts the movies one directory deeper, under `MPG`, where the executable resolves them. Coverage: `CSVM.Tests/OriginalManifestTests.cs`. +## src/UI/Menu/Original/SyntheticShell.cs +The `--synthetic-data` tree's Original shell, `extracted/rof/`: `fixtures/menu-layout-original/LAYOUT.CSV` +decoded by `Extraction/MenuLayoutDecoder.cs` into `menu_layout.json`, the invented string rows of +`fixtures/synthetic/rof/ui_strings.json` (a layout symbol's row, or a row the code reads by id), and +one generated picture per `fixtures/synthetic/rof/art.json` entry at that size. A file the asset +manifest requires with no size stops the build. The backdrop movies stay absent, the one optional +gap. It sits in UI because it reads the manifest, so `TreeFamilies` here composes the whole tree's +family list over `Tooling/SyntheticData.cs`'s. Read `OriginalAvailability.cs` for what the tree must satisfy. + ## src/UI/Menu/Original/OriginalAssetManifest.cs The versioned required/optional asset manifest, derived from the decoded layout rather than hand-listed. `Derive` classes the art of the sections Original composes required, less two short diff --git a/docs/architecture/Utils.md b/docs/architecture/Utils.md index 5474be34c..09a9d28c4 100644 --- a/docs/architecture/Utils.md +++ b/docs/architecture/Utils.md @@ -302,12 +302,12 @@ which is how a golden or a deterministic capture pins the mode on purpose. The m written up as a divergence in `docs/architecture/Spec.md`. ## src/Utils/WordSetting.cs -The source order the four word-valued graphics settings share (`AntiAliasingSetting`, `RenderScaleSetting`, -`ShadowQualitySetting`, `ViewDistance`), each holding one instance as its `Lookup` over its words, key and flag. `Resolve` +The source order the five word-valued graphics settings share (`AntiAliasingSetting`, `RenderScaleSetting`, +`ShadowQualitySetting`, `WaterQualitySetting`, `ViewDistance`), each holding one instance as its `Lookup` over its words, key and flag. `Resolve` takes the flag where the setting has one, then the saved word, then the config key, then the fallback the setting hands it; a word outside the list falls through, a key spelling the fallback reads as it, and an unknown config word the lookup reaches warns. The result is a `ResolvedWord` carrying a `SettingSource`, and `SourceName` spells that source for a log -line (`options.json`, the key, the flag, `default` or shadow quality's GPU rules). `ReadSaved` is the `--det` guard each +line (`options.json`, the key, the flag, `default`, or shadow and water quality's machine rules). `ReadSaved` is the `--det` guard each setting's `SavedWord` goes through. `Spec/SessionSpec.cs` parses a setting's flag through its `Lookup`. ## src/Utils/AntiAliasingSetting.cs @@ -329,6 +329,15 @@ casts, its angular distance and blur, and the renderer's soft filter and atlas e `Launcher.ApplyShadowQuality` at the sun's build and on every Options apply; it writes nothing on the faithful path and bumps `Revision`, which the cockpit pass re-copies the sun on. Why each level stands where it does: `analysis/screen-dither/FINDINGS.md`. +## src/Utils/WaterQualitySetting.cs +The Enhanced sea's water quality: `flat`, the sea-level sheet the faithful path draws, or `waves`, the wave ocean +(`Effects/Ocean.cs`) in its place. `Resolve` runs `WordSetting` over `--water-quality=`, the saved `waterQuality` word, +the `graphics.waterQuality` key, then `DefaultFor(det)`: `waves` under `--det`, otherwise `FallbackFor(linux, integrated)`, +which is `flat` on Linux or on an integrated GPU (`ShadowQualitySetting.IntegratedGpu`), so the Steam Deck runs flat, under +the source `default_linux_or_integrated_gpu`. `Pick` is the lookup without the store; `SavedWord` holds the `--det` guard. +`Launcher` resolves it at startup and on every Options apply, then `EnhancedLook.ApplyWaterQuality` has +`Launch/GameSession.cs` build or drop the ocean, which reads `DrawsWaves`. Only Built-in's Options screen shows a row. + ## src/Utils/ViewportQuality.cs What `AntiAliasingSetting` and `RenderScaleSetting` write on a 3D viewport, gathered here because there are four viewports to write them on: the root viewport `Launch/Launcher.cs` owns, and the SubViewports @@ -410,7 +419,7 @@ block, where the same predicate drives both window hiding and the interactive ru ## src/Utils/OptionsStore.cs Process-wide, version-tolerant JSON persistence for `OptionsDef`: the graphics mode, view distance and difficulty words, the six -display settings (monitor index, resolution, display mode, V-Sync, render scale, anti-aliasing), the Enhanced shadow quality, the four volume levels, the nearest-after-a-kill targeting switch, the default view a flight opens in, the automatic head turn, the remembered install folder (fully qualified or dropped), and the network callsign, voice and game name the Game and Player Information boxes remember. One file, `user://options.json`, +display settings (monitor index, resolution, display mode, V-Sync, render scale, anti-aliasing), the Enhanced shadow and water quality, the four volume levels, the nearest-after-a-kill targeting switch, the default view a flight opens in, the automatic head turn, the remembered install folder (fully qualified or dropped), and the network callsign, voice and game name the Game and Player Information boxes remember. One file, `user://options.json`, independent of `Session/Campaign/CampaignProfileStore.cs`. A missing or malformed file reads as empty (a malformed one moved to `.bad`), an unknown version invalidates it, an unknown value drops only that field, and a field the file does not carry reads as never set, which is why adding a field does not bump `Version`. Four reads hold that one contract: a word set (`DisplayWords`, `DifficultyWords` and `ViewWords` hold the vocabularies, whose resolved tier and view mode belong to `Flight`), a shape predicate for the diff --git a/docs/cli.md b/docs/cli.md index cd70f2382..9bbfe945b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,7 +49,7 @@ exactly how `PROJECT_CONTEXT.md`'s day-to-day table drifted from this page. One `--loadout` · `--rocket` · `--gun-select` · `--target` · `--fire` · `--fire-rockets` · `--incoming` · `--ai` · `--ai-attack` · `--ai-damage` · `--canopy-holes` · `--ai-targeting` · `--difficulty` · `--no-assist` · `--no-crash-loss` · `--generators` · `--zeppelins` · `--zep` · `--wake-turrets` · `--wake-generators` · `--craters` · `--infinite-ammo` · `--ammo` · `--weapon-lab` · `--weapon-mount` · `--weapon-fire` · `--weapon-cycle` · `--weapon-click` · `--weapon-target` · `--weapon-surface` · `--weapon-standoff` · `--weapon-camera` · `--weapon-test` · `--damage` · `--damage-hd` · `--destroy` · `--crash` **Debug labs, the scripted twin of each interactive lab key** -`--debug-livery` · `--debug-mesh` · `--debug-select` · `--debug-nodelab` · `--debug-damage` · `--debug-names` · `--debug-fps` · `--hud-text` · `--debug-join` · `--debug-waves` · `--debug-wingmen` · `--debug-preset` · `--debug-pointer` · `--debug-marquee` · `--debug-graphics-switch` · `--debug-shaders` · `--debug-scoreboard` · `--debug-scores` · `--debug-race-end` · `--debug-wash` · `--debug-anim-ui` · `--debug-collision` · `--debug-dzpaths` · `--debug-ainets` · `--debug-targets` · `--debug-markers` · `--debug-spectate` · `--debug-pause` · `--debug-load` · `--debug-objective` · `--collision` · `--debug-colliders` · `--debug-classoverlay` · `--debug-tilegrid` · `--debug-clutterflag` · `--hide-alpha` · `--markers` +`--debug-livery` · `--debug-mesh` · `--debug-select` · `--debug-nodelab` · `--debug-damage` · `--debug-ocean` · `--debug-names` · `--debug-fps` · `--hud-text` · `--debug-join` · `--debug-waves` · `--debug-wingmen` · `--debug-preset` · `--debug-pointer` · `--debug-marquee` · `--debug-graphics-switch` · `--debug-shaders` · `--debug-scoreboard` · `--debug-scores` · `--debug-race-end` · `--debug-wash` · `--debug-anim-ui` · `--debug-collision` · `--debug-dzpaths` · `--debug-ainets` · `--debug-targets` · `--debug-markers` · `--debug-spectate` · `--debug-pause` · `--debug-load` · `--debug-objective` · `--collision` · `--debug-colliders` · `--debug-classoverlay` · `--debug-tilegrid` · `--debug-clutterflag` · `--hide-alpha` · `--markers` **Dumps and the test harness, report text plus a verdict, then quit** `--dump-markers` · `--dump-weapons` · `--dump-loadout` · `--dump-flight` · `--dump-config` · `--dump-mips` · `--dump-ai` · `--dump-sticks` · `--dump-tilegrid` · `--run-tests` · `--damage-test` · `--effects-test` · `--hud-font-test` @@ -58,7 +58,7 @@ exactly how `PROJECT_CONTEXT.md`'s day-to-day table drifted from this page. One `--log` · `--perf` · `--gc-types` · `--debug-finalizers` · `--debug-anim` · `--debug-net` · `--debug-net-trace` · `--anim-lod` · `--hitch-inject` **Rendering probes, is this thing drawing at all?** -`--tex-override` · `--tex-census` · `--no-fog` · `--no-flare` · `--no-clutter` · `--clutter-templates` · `--cloud-jitter` · `--no-zone-cull` · `--sky-zone` · `--mips` · `--no-cockpit-pass` · `--graphics` · `--shadow-quality` · `--view-distance` · `--shader-warmup` · `--no-ssao` · `--no-ssr` · `--no-glow` · `--no-soft-shadows` +`--tex-override` · `--tex-census` · `--no-fog` · `--no-flare` · `--no-clutter` · `--clutter-templates` · `--cloud-jitter` · `--no-zone-cull` · `--sky-zone` · `--mips` · `--no-cockpit-pass` · `--graphics` · `--shadow-quality` · `--view-distance` · `--water-quality` · `--shader-warmup` · `--no-ssao` · `--no-ssr` · `--no-glow` · `--no-soft-shadows` · `--no-ocean` · `--dump-ocean-mask` **Map-edge continuation, the fold past the map boundary** `--map-edge-block` · `--map-edge-mode` @@ -67,7 +67,7 @@ exactly how `PROJECT_CONTEXT.md`'s day-to-day table drifted from this page. One `--hold` · `--lever` · `--yaw` · `--pitch` · `--look` **Data paths, override where the extraction is read from** -`--data-root` · `--gamez` · `--textures` · `--zrdr` · `--interp` · `--sounds` · `--zip-assets` · `--messages` +`--data-root` · `--synthetic-data` · `--gamez` · `--textures` · `--zrdr` · `--interp` · `--sounds` · `--zip-assets` · `--messages` **Extraction, build `extracted/` from an original install and exit** `--extract` · `--extract-force` · `--extract-unzip` · `--unzbd` @@ -95,7 +95,7 @@ the shared selection (`--debug-select`), the node lab (`--debug-nodelab`), the w **Written exceptions to "one flag, one bullet":** `--spawn-dir` shares `--spawn-at`'s bullet, since the pair is one mechanism, so `Grep` the partner's name to find it; `--debug-nodelab` and `--debug-damage` each carry a short opener bullet plus the full behaviour under their lab's own -section further down. The counts reconcile as **161 index entries, 161 parser flags and 162 bullet +section further down. The counts reconcile as **162 index entries, 162 parser flags and 163 bullet lines**. ## Flags @@ -156,7 +156,7 @@ lines**. - `--paint-seed=N` (seed the random-livery RNG directly, overriding the `paint` stream `--seed`/`--det` would derive, pins liveries alone in an otherwise unpinned run) - `--damage[=part:frac,...]` (**decide whether the damage lab opens at launch**; every session with an aircraft builds one, reachable on **F19**. On its own it implies `--viewer` and drives the parked plane, whose per-part HP sliders play the torn-skin flips, panel fires and nose smoke. With an explicit `--fly`/`--stunt` it stays in flight, where the sliders write the aircraft's real per-part HP. Presets take a fraction of 0-1 or a percent above 1 (`--damage=leftwing:0.25,nose:40`). Ignored by `--freecam`/`--anim-lab`, which have their own world damage lab. Splitscreen: P1-only) - `--chapter[=C1]` (which chapter world to build, flown by default, or rendered statically with `--viewer`; takes `C1`/`C1B`/`C1C`/`C2`/`C2B`/`C3`/`C4`/`C5`, drives the default gamez to `/gamez.zip` and textures to `/texture.zip`; the world node name is always `world1`) -- `--stage=empty` (**replace the chapter world with a synthetic test stage**: a flat, collidable 20 km ground plane under a code-drawn 100 m grid, the default sky and sun, with the plane over the grid origin. No chapter gamez, mission setup, clutter, animation program, weather, skydome or edge extender is built unless `--zep=` grafts one in, leaving the aircraft, its HUD, flight, collision and both weapon paths. With no chapter gamez, rockets fly without their FLYOUT body, pylons carry no mounted ordnance, and an impact draws the spark fallback. An unknown stage name is reported, not guessed) +- `--stage=empty` (**replace the chapter world with a synthetic test stage**: a flat, collidable 20 km ground plane under a code-drawn 100 m grid, the default sky and sun, the plane over the grid origin, and under `--vs` every seat on the stage's 600 m ring or its team's own block. No chapter gamez, mission, clutter, animation program, weather, skydome or edge extender is built unless `--zep=` grafts one in. Without that gamez, rockets fly without their FLYOUT body, pylons carry no mounted ordnance, and an impact draws the spark fallback. An unknown stage name is reported, not guessed) - `--node=` (in `--viewer`, the default for this flag, or `--anim-lab`, **build only the named gamez subtree** out of `--chapter=`'s world, with the camera auto-framed on it. Matching is on the source `cs_name` meta, never the sanitized Godot node name. Multiple matches are normal: the first is built, and the ambiguity is a warning. A name matching nothing lists the candidates containing it and quits cleanly (exit 0). The mission's interp script and the clutter are skipped, but the animation program still loads and binds. `--pos`/`--direction`/`--lookat` override the framing) - `--fly` (**the default**: free flight, a chapter world with skydome and plane, on the original's own keymap (`docs/controls.md`): the arrows pitch and roll, `,`/`.` rudder, `=`/`-` throttle and the digits set it outright, Backspace respawns, **Esc** (or gamepad Start) halts the sim and opens the pause board's Resume, Restart and Exit menu, where the chase camera free-orbits the frozen plane, and **`.`** advances one sim frame while halted. It defaults to C1; combine it with `--chapter=` for another. It spawns from the mission's `ia.json`, so see `--mission`/`--scenario`/`--spawn`) - `--stunt` (`--fly` plus the mission's Danger Zones (`ia.json` `dzones`) as fly-through objectives, spawning from the `stunt_flying` list, which it forces unless `--scenario=` is explicit. The objective marker HUD draws the marker, edge arrow and bearing with the run timer; completing every zone raises the results scoreboard, which halts the sim (**R** restarts). CLI only: the launchscreen's stunt row attaches an Instant Action mission instead. With `--players=N` it is a 5-minute time attack ranked by best run on the board it ends on; bests record under the solo key) @@ -251,7 +251,7 @@ lines**. - `--perf`'s **frame split**: `sim_ms` splits the tick by phase and `proc_sites_ms` the pass by consumer, each slot `mean/max`. `phys_engine_ms` (the flush and physics step after each tick), `defer_ms` (the end-of-frame flush, every `_Draw` in it), `draw_ms` (the rendering server's frame and present) and `idle_ms` are per frame, so they, `proc_ms` and ticks per frame times `phys_tick_ms` sum to `frame_ms`. `setup_ms` is the instance update inside `draw_ms` that `render_cpu_ms` leaves out (PERF-40) - `--perf`'s **GC readout**, one `[perf] gc up_s= win_s= pause_ms= pause_per_s_ms= gc0= gc1= gc2= fin= fin_per_s= alloc_mb_s= heap_mb=` line per ten wall seconds (`GcTrace`). `fin` is how many FINALIZABLE objects died, which is what the pause tracks; `up_s` is process uptime, so drop the windows under about 50 s, which are the world build settling. **Judge a change on `fin_per_s` and `pause_per_s_ms`**, never on the per-collection pause (PERF-19, PERF-20) - `--perf`'s **per-phase allocation row**, one `[perf] alloc sim_frame= sim_alloc_b=` line beside each window, the same session-simulation phases `sim_ms=` names in allocated BYTES, `mean/max` per TICK (`SimPhaseCost`). It answers the question the millisecond row cannot: a GC pause lands in whichever phase happens to be open (PERF-34), so the phase that reads expensive is rarely the one feeding the collector. Read it with `[perf] gc`'s `alloc_mb_s`, which is the whole process including the `_Process` pass, and expect a settled tick's phases to sum to a few tens of KB -- `--perf`'s **spyglass line**, one `[perf] spyglass sim_frame= discs= disc_draws= disc_shadow_draws=` line beside each window in which a spyglass disc rendered (`SpyglassView.Census`): the mean live discs and their visible and shadow draw calls per frame. Each disc is a viewport of its own, so `gpu_ms` leaves it out and `draws` folds it into the frame total (PERF-47). Under Enhanced `disc_shadow_draws` reads 0, since the discs are lit by a shadowless copy of the sun +- `--perf`'s **spyglass line**, one `[perf] spyglass sim_frame= discs= disc_draws= disc_shadow_draws= disc_prims= disc_gpu_ms=` line per window in which a disc rendered (`SpyglassView.Census`): mean live discs and their draws, primitives and GPU time per frame, read on the render thread (PERF-43). `gpu_ms` leaves the discs out and `draws`/`prims` fold them in (PERF-47). Under Enhanced `disc_shadow_draws` reads 0 (a shadowless sun copy), and `disc_prims` still counts the ocean grid the disc submits and collapses (`SplitScreen.FlatSeaLayer`), so read `disc_gpu_ms` for cost - `--gc-types` (implies `--perf`; adds one `[perf] gc-types win_s= samples= per_s= top=Type:count,...` line per GC window, the types the runtime's allocation sampler saw most. One sample per ~100 KB allocated, so the tally is a share of BYTES: a small wrapper type high on the list is made in very large numbers, which is how `fin_per_s` is traced to the call making the wrappers. The listener's own event objects (`MoreEventInfo`, `EventWrittenEventArgs`, boxed payload) head every list and inflate `alloc_mb_s`, so read past them and never judge allocation on such a run) - `--debug-finalizers` (with `--run-tests`: each suite runs with the .NET finalizer thread parked and a collection forced every 20 ms, then the queue is drained before the next suite, and a `[test] finalizer gate suite= held= collections= handle_errors=` line follows the suite's verdict. A Godot wrapper whose object was reached natively after the wrapper died logs `Handle is not initialized` from that drain, so the error lands beside the suite that dropped the wrapper instead of wherever the finalizer thread happened to run. Slow on world-building suites; `docs/verification.md` INSTR-99) - `--no-vsync` (**a measurement flag, not a display one**: it uncaps the frame loop (vsync disabled, @@ -283,6 +283,7 @@ lines**. - `--debug-select[=x,y[,up]]` (`--freecam`/`--anim-lab` only, the **scripted twin of clicking an object and pressing PgUp**. On the first frame it casts the same pick ray a click would, at screen position `x,y` (default the middle of the viewport), then walks `up` rungs toward the world root. In any other mode it warns and is ignored. The report goes to the `ui` log category and is the whole ladder, one `select rung=...` line per rung with the current one starred. **Pin the camera with `--pos`/`--direction` first**: a screen-position pick is meaningless without the pose it was taken at) - `--debug-nodelab[=spec]` (`--freecam`/`--anim-lab` only, open the **node lab** (N) at launch and dump its readouts to the `ui` log category. Tokens: `deps`, `dest`, `open`, `node=`; full behaviour under "The node lab" below) - `--debug-damage[=script]` (`--freecam`/`--anim-lab` only, open the **world damage lab** (F19) at launch and run an ordered damage script against the selected destructible, logging each step to the `ui` log category. Steps: `node=`, `pool=`, `hp=`, `kill`, `reset`, `tick=`, `open`. Forces the collision build on so the kill's collider census is real; full behaviour under "The world damage lab" below) +- `--debug-ocean[=spec]` (`--freecam` only, the scripted twin of the **ocean lab** (Shift+F1): apply `:` overrides over the chapter's saved sea for this launch, so a `--screenshot` shows a setting nobody saved; `open` shows the panel. Field names, ranges and the fold limit are `Effects/SeaState.cs`'s `Fields`; values clamp, unknown tokens are reported. The lab's Save writes the chapter's entry in `CSVM/data/ocean_seas.json` from the source tree. Example: `--debug-ocean=height:1.4,length:1.5,open`) - `--markers` (**implies `--viewer`**: open the **marker overlay** at launch, the firepoint, pylon and target gizmos on the parked aircraft. Firepoints draw orange, firepoints sharing a mount magenta, pylons cyan and the aim `target` green. Every gizmo dot shows; the labels de-clutter nearest-first. Toggle it in any `--viewer --plane` session with **K**, or combine it with `--screenshot`. Only the parked plane carries a marker rig, so a `--viewer --chapter` world has none, and `--viewer` forces `--players=1`, so the overlay is not reachable in splitscreen) - `--dump-markers[=plane]` (implies `--det`; print each player airframe's marker rig to stdout and `./.scratch/markers_dump.txt`, then quit. It needs no world or camera, so `--headless` makes it windowless. Each block lists every `firepoint*`/`pylon*`/`target` with its plane-frame position (nose −Z, right +X, up +Y), a firepoint's mirror pair and any co-located mounts. The `docs/formats/markers.md` tables regenerate from it, and the `markers-rig` suite asserts the same report. The optional value filters to one airframe by model node or display name; an unmatched value lists the names) - `--dump-weapons[=id|name]` (implies `--det`; print all 48 `BALLISTICS` defs of `weapons.json` (`WeaponDefs`) to stdout and `./.scratch/weapons_dump.txt`, then quit. Each block gives the id, `NAME`, the `DESC` display name resolved through `messages.json`, the ballistics, damage and allotment numbers, the class flags and the `FIRE`/`FLYOUT`/`IMPACT` bindings. A clean run reports **no unhandled keys**, the tripwire for data growing a key the reader has not learned; `--run-tests=weapons-defs` asserts it and the 48 count. Optional value filters by id or `NAME` substring) @@ -346,7 +347,7 @@ lines**. - `--damage-test[=name]` (implies `--det`; the **headless destructible sweep**. It builds the `--chapter` world via the freecam path, then for one live destructible per distinct `DAMAGE_SEQUENCE`-carrying def sweeps its HP from full to zero and logs **which stage effect fires at which health**, plus a `resolve✓` check that a deep descendant node, what a projectile's raycast strikes, maps back to that destructible; to stdout and `./.scratch/damage_test.txt`, then quits, windowless under `--headless`. Optional value filters destructibles by `NAME` substring, e.g. `--damage-test=ap_h2otwr`) - `--damage-hd=` (with `--damage-test`, switch to **discrete-hit mode**: apply `n` `HEALTH_DAMAGE` per hit through `AnimRuntime.DamageAt` and count hits to destruction, rather than sweeping HP. It logs the stage-crossing hits and, at death, a `swap[...]` subtree check, a `col[off N, on M]` collider census (WORLD-10), a `debris[N launched]` count. Discrete mode covers every destructible, not only `DAMAGE_SEQUENCE` ones. It forces `Collision` on, since a freecam world builds none (WORLD-9), and dumps the collidable-mesh inventory to `./.scratch/world_colliders.txt`) - `--effects-test` (implies `--det`; build the `--chapter` world via the freecam path, then play every impact and destruction effect at the camera point, reporting whether each **resolves** and whether it **builds a puffer** (`verification.md` WORLD-12: a started def whose factory or textures are absent renders nothing), to stdout and `./.scratch/effects_test.txt`, then quit. `--det` pins the seed the runtime's dice derive from, and every effect is stopped before the next because they share `trailpuffer2`. Point-light and container effects build no puffer by construction) -- `--run-tests[=filter]` (implies `--det`; the **in-engine test harness**: run the assertion suites, print a PASS/FAIL/SKIP table and `./.scratch/test-report.json`, and **exit 1 if any suite failed**, 0 otherwise (LOG-12); an absent input reports SKIP and still exits 0 (DIAG-15). The selector is comma-separated terms, unioned in registry order: `suite:` one exact suite, `tier:` a tier, anything else a suite-name substring; a term matching nothing exits nonzero before any suite runs. `shard:/` divides that choice by `analysis/engine-suite-weights.json`. Run windowed (LOG-8)) +- `--run-tests[=filter]` (implies `--det`; the **in-engine test harness**: run the suites, print a PASS/FAIL/SKIP table and `./.scratch/test-report.json`, **exit 1 if any suite failed** (LOG-12); an absent input reports SKIP and exits 0 (DIAG-15), except that `tier:ci` fails a listed suite's skip. Terms are comma-separated, unioned in registry order: `suite:` exact, `tier:quick` or `tier:ci` a tier, else a name substring; a term matching nothing exits nonzero first. `shard:/` divides that choice by `analysis/engine-suite-weights.json`. Run windowed (LOG-8)) - `--destroy=` (kill a named world destructible at build, unattended. `` is a case-insensitive substring of a destructible's def, animation or anchor `cs_name`; every distinct match dies, capped at 64, and a dormant pool (a deactivated zeppelin's parts) refuses the hit and is logged as refused, not counted. It reuses the weapon-hit path, so debris, effects and audio match a rocket kill. It needs a `--chapter` world, forces no mode, and is a no-op in `--viewer`. In `--freecam` it auto-frames the object unless `--pos`/`--direction` placed the camera. `--damage-test` lists the names) - `--crash[=frame]` (force the flying players' crash rig at a fixed sim frame, default 5: `FlightController.DebugForceCrash()` fires on every rig when the clock reaches it, which is the only headless route to a crash. A no-op past the first fire, and inert without `--fly`/`--stunt`. The wreck scatters off the `Rng.Crash` stream, so pin `--seed` and the player count. It needs a `--chapter` world, the crash def coming from the mission's `AnimProgram`, so it is inert under `--stage=empty` unless `--zep=` grafts a program in. Spawned AI planes crash too, indexing the `ai_crash_*` family) - `--menu[=]` (forces the launchscreen with other args, else bare only. A value opens it, an unknown one the top level, Instant Action's the Mode pick. A `:` suffix is its argument: `campaign-briefing` seconds, `join-board` pads, `keys` a category, `loadboard-campaign` a mission, `pauseboard` a mission and a marked count, `pauseboard-ia` a chapter and type, else a `CampaignAidScript`: counted `dulrab` moves and confirms (`4da`), a number steps. `x` is Built-in's; Original refuses it: exit 1, no shot. Campaign aids use scratch stores bar `campaign-fly`; see `docs/org/menu-inventory.md`) @@ -370,6 +371,7 @@ lines**. - `--spawn-at=x,y,z` / `--spawn-dir=x,y,z` (**deprecated**, the pre-A5 flight-only spelling of `--pos`/`--direction`; each logs `WARN [core] deprecated flag=… use=…` once per run and otherwise behaves exactly as it did. They still carry their old per-mode meaning, which `--pos` deliberately does not copy: in `--anim-lab` `--spawn-at` moves the parked stage prop as well as the camera, and in `--freecam` it moves the camera through the spawn path, where `--pos` beats it) - `--sky-zone=zone1|zone2|zone3` (horizon zone, default `zone2`. It also selects that zone's distance fog and enables fog, whiteout and the map-edge extension in a static view. **The names are per chapter**: C1 to C4 ship `zone1`+`zone2`, **C5 ships `zone1`+`zone3`**, and a zone the chapter does not define falls back to the first its `weather.json` lists. The default is also corrected by the horizon's geometry: where the named zone builds no dome, the one that has renders. **Passing the flag explicitly opts out of that correction**, pins the fog and forces the `zone_id` gate to that zone) - `--data-root=` (where `extracted/` lives; default the repo root. Also settable as the **`CSVM_DATA_ROOT`** environment variable, which the flag overrides. It exists so a **git worktree can run the game**: `/extracted/`, `/CrimsonSkiesGame/` and `/tools/` are git-ignored, so a worktree checkout has none of them. Point it at the primary tree and a worktree session renders **byte-identically** to that tree. It affects asset lookup only: `planes.zip`, `zrdr.zip`, `soundsh.zip`, `interp.json`, `messages.json`, `rof/`, and the per-chapter `gamez`/`texture` and mission `zrdr` paths) +- `--synthetic-data` (**read an invented test tree instead of the data root**: writes an `extracted/` of `CSVM.Tests/fixtures/` records and generated files into `.scratch/synthetic-data//`, stamped synthetic, and reads it in place of the root named. Logged as `WARN [core] SYNTHETIC DATA`; `test-report.json`'s `syntheticData` reads true. An unwritable tree quits 1. With no `--plane=` it flies the armed stand-in `probe_plane`. Needs a repo checkout, so an export refuses it; dropped beside `--extract`. `Tooling/SyntheticData.cs`) - `--gamez=` - `--textures=` - `--zrdr=` (default extracted/zrdr.zip) @@ -385,11 +387,14 @@ lines**. - `--graphics=original|enhanced` (the opt-in lit-world mode, default `original`. Precedence: **this flag**, then the **saved `graphicsMode` option** (`user://options.json`), then the **`graphics.mode`** config key, then `original`. Under `--det` only the flag survives, so it is the one way to ask for the enhanced path in a scripted or golden capture. It resolves at launch (`GraphicsMode.Resolve`), announced on the `[world] graphics mode:` line; an Options apply or **G** in flight switches it live after that. An unrecognised word at any layer warns and falls back to `original`) - `--shadow-quality=off|low|medium|high|ultra` (the Enhanced sun's shadow level. Precedence: **this flag**, the saved `shadowQuality` option, the `graphics.shadowQuality` config key, then `ultra`; an integrated GPU (the Steam Deck) falls back to `high`, or to `off` at 3 or 4 panes (`shadow_source=default_integrated_gpu_panes`). `--det` keeps only the flag, over `ultra`. Announced on the `[world] graphics mode:` line and per splitscreen session; an Options apply re-resolves it live. The faithful path ignores it; an unknown word warns) - `--view-distance=normal|far|veryfar|unlimited` (**how far Enhanced draws the clutter** before its fade: 1x, 2x or 4x the Enhanced fade, or no fade, the fog unchanged. Default `far`. Precedence: **this flag**, then the saved `viewDistance` option, then the `graphics.viewDistance` config key, then `far`. Under `--det` only the flag survives. An unknown word is dropped with a note. Original ignores it and keeps the decoded fade) +- `--water-quality=flat|waves` (**whether Enhanced draws the wave ocean** or the flat sea sheet. Precedence: **this flag**, the saved `waterQuality` option, the `graphics.waterQuality` config key, then the machine's fallback: `waves`, or `flat` on Linux or an integrated GPU, which covers the Steam Deck (logged as `water_source=default_linux_or_integrated_gpu`). `--det` keeps only the flag, over `waves`. Announced on the `[world] graphics mode:` line; an Options apply builds or drops the ocean live. `--no-ocean` still wins. Original ignores it; an unknown word is dropped with a note) - `--shader-warmup=auto|load|off` (**whether a load compiles the other graphics mode's shaders**, so a live switch only moves materials onto shaders Godot already compiled. `auto`, the default, warms once this process has switched. `load` always warms, and in an Original process that has drawn no Enhanced frame it also draws one hidden TAA frame, so Godot builds its advanced variants at load (6 to 10 s of first frame on C1 to CM24) rather than on the first switch. `off` never warms; a shader not made yet costs its compile on the switch that needs it. An unknown word is dropped with a note) -- `--no-ssao` (**enhanced bisect door**: leave the ambient-occlusion pass out of the `--graphics=enhanced` build. The four doors here accumulate on one command line and reach enhanced alone, the faithful path building none of these passes; together they bisect a full-screen artefact to the pass that draws it, one pose per closed door. ⚠ Read the result on a plain `--screenshot=`, never a `--shots` burst, whose frames carry `--jitter`'s camera dither) +- `--no-ssao` (**enhanced bisect door**: leave the ambient-occlusion pass out of the `--graphics=enhanced` build. The five doors here accumulate on one command line and reach enhanced alone, the faithful path building none of these passes; together they bisect a full-screen artefact to the pass that draws it, one pose per closed door. ⚠ Read the result on a plain `--screenshot=`, never a `--shots` burst, whose frames carry `--jitter`'s camera dither) - `--no-ssr` (**enhanced bisect door**: leave screen-space reflection out, so the water draws with its own specular alone) - `--no-glow` (**enhanced bisect door**: leave the glow pass out. The AgX tonemap stays and has no door of its own, every enhanced frame's exposure depending on it) - `--no-soft-shadows` (**enhanced bisect door**: zero the sun's angular distance and shadow blur together, so the shadow is still cast but with a hard edge. That isolates the penumbra filter, which resolves through a sample disc rotated per screen pixel, from the shading the shadow itself carries) +- `--no-ocean` (**enhanced bisect door**: leave the wave ocean (`Effects/Ocean`) out, so the flat sea-level sheet draws as it does in a chapter without one. The ocean builds only on a chapter with a sea at y = 0 (every chapter but C4) in `--fly`/`--freecam` at the `waves` water quality, so elsewhere the door changes nothing. Announced as `ocean: skipped (--no-ocean)`. It is also the flat-sea arm of an ocean A/B: same pose, with and without the flag) +- `--dump-ocean-mask=` (**inspection aid**: write the wave ocean's baked shore mask as a PNG when the ocean builds, R the sea coverage and G the distance to the nearest shore, surf or solid texel (saturating at 160 m), and the base sheet's tint beside it as `.tint.png`. Unlike the other `--dump-*` flags it does not end the run, so pair it with `--screenshot=`. Writes nothing where no ocean builds; the `ocean: built` line beside it carries the bake's size, `ms=` and its per-phase split) - `--no-fog` (**inspection aid**: neutralise the distance-fog *range* and the cloud-band whiteout so geometry stays visible to the horizon. It also opens the band's per-object zone gate. It leaves `WorldLight` and `FOG_COLOR` **unchanged**, so a shading question stays answerable with fog off. It is implemented by pushing every view's fog range (`csky_fog_range` and the per-view table) to `Weather.NoFog`'s no-op value, not the `csky_fog_on` instance uniform (see `Clutter.ShaderCode`); every fogged shader reads it: world, clutter, city blocks, dome. Announced in the `weather [...]` log line) - `--no-flare` (**inspection aid**: suppress the sun's lens flare in the two chapters that author one (C2, C3). The flare's full-screen wash reaches α ≈ 0.66, survives terrain occlusion and whitens the HUD as well as the world, so in C2 and C3 *any* capture with the sun near screen centre is contaminated for unrelated comparisons. The flare is announced in the `lens flare [...]` log line either way. It is otherwise gated purely on chapter data, a gamez `sun` node in the horizon subtree plus `LensFlareTexture` slots in `support\\init.gw`, so the other six chapters need no flag) - `--no-cockpit-pass` (**opt out of the cockpit pass**, the shipped path that draws the `cockpit1` interior in a `SubViewport` world of its own with the panel and the camera both at the origin, composited over the main view under the HUD (`Flight/Hud/CockpitOverlay`); the flag leaves the interior in the main world instead, for A/B comparison. Without the pass each dial face rounds differently once the attitude is rotated, which reads as panel jitter. **Costs a second 3D pass per player.** Not covered: an airframe swap, which rebuilds the interior and leaves the pass hiding itself) diff --git a/docs/controls.md b/docs/controls.md index 35b8dd9c8..80de7f7da 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -268,4 +268,5 @@ by about 1.15; Built-in prints the value as a multiplier, `1.00x`. | `F20` | show the built colliders, coloured by the surface id they resolve to (see `--collision`), also bound in `--fly`/`--stunt` | `--debug-colliders` | | `F21` | colour world objects by class (destructible/facade/clutter/scenery), also bound in `--fly`/`--stunt` | `--debug-classoverlay` | | `F19` | damage lab on the selected destructible | `--debug-damage=` | +| `Shift+F1` | ocean lab: the chapter's sea on sliders, live, with Save into `CSVM/data/ocean_seas.json`. `--freecam` on a sea chapter only, where no flight control reads `F1`. Outside the F13 to F24 debug range so the lab works without the extended keypad | `--debug-ocean=` | | `F18` | anim lab: the def picker. Moved off `F`, which the camera's lock key now owns, the camera polls raw key state, so one key could not serve both (`BL-428`) | | diff --git a/docs/formats/loadouts.md b/docs/formats/loadouts.md index 4829e95c8..08f80d320 100644 --- a/docs/formats/loadouts.md +++ b/docs/formats/loadouts.md @@ -43,6 +43,7 @@ Keyed by **vehicle def name** (`vehicle.json`), matching `PlaneStats`. Per plane | `guns[].ammo` | `slug` / `dumdum` / `ap` / `magnesium`, stock is always `slug` | | `guns[].markers` | the firepoint node(s) this group fires from (see binding rule) | | `guns[].turret` | present + `true` on turret slots, parsed but **inert in M3** (see below) | +| `guns[].weapon` | optional `wep_*` id that replaces the caliber + ammo resolution below; no stock plane carries it. The synthetic tree's stand-ins name their invented gun this way, from a second file of this shape (`StockLoadouts.Supplement`), whose plane replaces any committed one on the same `model`. `Loadout.ForRig` keeps the id on the slot that names it | | `hardpoints.count` | number of underwing pylons carried; **which** physical `pylonN` markers get used is `Loadout.PylonFillOrder`, not `1..count` (below) | | `hardpoints.stock[]` | the `wep_*` id each pylon carries in stock fit, in `PylonFillOrder` order; its length equals `hardpoints.count` | diff --git a/docs/formats/net-spawns.md b/docs/formats/net-spawns.md index 0a29aedc0..d792c9c00 100644 --- a/docs/formats/net-spawns.md +++ b/docs/formats/net-spawns.md @@ -116,6 +116,12 @@ mission that ships no `ia.json`. That is the original's own gate: the un-teamed that reads block 0, and no other mode reads the file at all. Every Dogfight launched from a menu flies its type's `MP` mission (`SessionSpec.FromMenu`), a Deathmatch `MP1`, so a menu launch always opens on this table; a command-line `--vs` reads it only beside `--mission=MP1`, `MP2` or `MP3`. +A `--vs` on `--stage=empty` reads no file: it walks `EmptyStage.SpawnRing`, a free-for-all block of +the same records built in code, which is invented and stands for no shipped map. A team match there +walks `EmptyStage.SpawnTable`, that ring as block 0 followed by four team blocks in the staging-stack +shape above: block N is four positions 100 m apart on four rungs 100 m apart, at team N's base +1.5 km out, the whole block facing the origin. It is the same 80-entry layout `MP1` authors, so +`TeamBlocks` and the respawn walk run on it unchanged. A team Dogfight reads the whole table instead (`SpawnPoints.LoadNetTable`). `SpawnPoints.TeamBlocks` opens a seat on lobby team N at block N's entry for its place among that team's seats, and a seat diff --git a/docs/menu-presentations.md b/docs/menu-presentations.md index 8717c62c0..bd4d0692c 100644 --- a/docs/menu-presentations.md +++ b/docs/menu-presentations.md @@ -344,8 +344,10 @@ through an `OptionsApplyExit` carrying every choice, whichever page it was sent keeps its one writer. Both graphics choosers cover `original` and `enhanced`, and the apply switches the running world at once (`Launcher.SwitchGraphicsMode`), so neither description owes a restart. Built-in's screen adds a View Distance row under its chooser (`Utils/ViewDistance.cs`), live the -same way and marked as Enhanced only. The Original VIDEO page has no line for it and hands the -saved word back unchanged. The +same way and marked as Enhanced only, and a Water Quality row under its shadow quality +(`Utils/WaterQualitySetting.cs`), dead under Original, whose apply builds or drops the wave ocean +on the flying world. The Original VIDEO page has no line for either and hands both saved words +back unchanged. The startup recovery is `--force-builtin`, which beats everything and rewrites nothing. **What an Options page focuses with, and what it draws focus as.** Two choices on these pages are diff --git a/docs/tooling.md b/docs/tooling.md index 5e9d430cd..b0c7888e7 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -126,6 +126,46 @@ checked in, as the `[Trait("Tier", "Quick")]` classes and `SuiteCatalog.QuickTie `-Suite`/`-Filter` unions with the engine tier and a `-UnitFilter` replaces the unit tier. Quick prints a `not checked:` line per omitted surface, and never satisfies the landing gate. +**The ci engine tier (`--run-tests=tier:ci`) is what CI requires, and a skip in it fails.** Its +membership is checked in as `SuiteCatalog.CiTier`, one name per line and sorted. A suite earns its +place by passing `RunCiSuites.ps1` (below): headless, with no extraction but the `--synthetic-data` +tree of invented records, with no IPv6 loopback and no OS shell or real display, and by being shown +to go red when one of its inputs in that tree is broken. It joins when it stops needing the install, +and leaves rather than taking an exception when it starts needing one. The plane and Original-shell +suites skip without the switch, so the tier is only green with it. The suites on the `headlessOnly` +list in `analysis/headless-limits.json` (a unit test keeps them off the tier), `enet-dual-stack`, +`enet-stable-ipv6-reply` and `build-stamp-focus` are out for that reason. +The ENet and LAN discovery suites bound to `127.0.0.1` are in. A network match suite flies the +install's `C1/MP1` (Capture the Flag `C1/MP2`) when the data root carries it, and otherwise +`--stage=empty` with its own spawn table and arena of flags and rearm nodes, and notes which. The tier carries +`SuiteTier.SkipFails`, so a listed suite that SKIPs reports FAIL with its skip reason and the run +exits nonzero; any other selector, including `suite:` on a listed suite, keeps SKIP as a +non-failure, so the local battery is unchanged. The tier checks mechanisms on invented or absent +data and never replaces the full local battery. A suite whose install reads sit in a few legs +splits rather than staying off: those legs keep the original name and its `RequireData` gates, the +rest move to a `-core` suite on the tier, and no leg runs in both, so the local battery still +makes every check once. + +**`RunCiSuites.ps1` is the CI engine stage, and runs locally on Linux or macOS the same way.** The +`engine` job in `.github/workflows/checks.yml` runs it on `ubuntu-latest` after `dotnet build +CSVM/CSVM.sln` and one ` --headless --path CSVM --import`, with Godot 4.7 .NET downloaded +from the official release, checked against the release's `SHA512-SUMS.txt` and cached per release. +A contributor runs those two commands and then `./RunCiSuites.ps1 -Godot ` +(`-Selector` for another selector than `tier:ci`, `-NetPortBase`, `-TimeoutSec`). It starts one +headless Godot with `CSVM_DATA_ROOT` at a fresh empty folder, `--synthetic-data` (so the run reads +the invented tree written for its process id, and the report must name that root and +`syntheticData: true`; `-NoSyntheticData` runs over the empty folder alone) and the XDG folders inside +`.scratch/ci-suites/`, which it wipes first and which receives `godot.out`, `godot.err` and a copy of +the screened engine log; the job uploads those, `.scratch/test-report.json` and `.scratch/logs/` on +every outcome. ⚠ **The verdict comes from the report, not from Godot's exit code.** A headless +process prints engine error lines a windowed one does not, so a bare headless `--run-tests=tier:ci` +reports `menu-player-setup-journey`'s text-server lines as unexpected and exits 1. The script +excuses an unexpected line only when it matches a `headlessEngineErrors` pattern in +`analysis/headless-limits.json`, the same patterns and the same mechanism `sandbox/LinuxRelease.ps1` +uses; any other unexpected line, an allowance over its cap, an unscreened log, a FAIL, a missing or +stale report, or a crash exit fails the run. It refuses to start on Linux without +`libfontconfig.so.1`, for the reason the Linux release check gives. + **The engine stage runs the full catalog in concurrent Godot processes.** `-Shards ` sets how many; the default is 6 for a full run and 1 whenever `-Suite`/`-Filter`/`-Quick` names a selection, and `-Shards 1` is the serial reference path. Membership comes from the harness's @@ -592,9 +632,11 @@ What the check had to learn: the harness would report the engine log unscreened. Without the flag, each process logs to its own `user://logs/godot.log` and the harness screens that. - **Some suites cannot pass headless on any platform.** They read back what only a renderer or a - display produces (mesh and MultiMesh instance data, viewport pixels, windows and screens). The - `$HeadlessOnly` table at the top of the script lists them with the reason each fails, beside - `$HeadlessEngineErrors`, the engine error lines only a headless process prints. The same + display produces (mesh and MultiMesh instance data, viewport pixels, windows and screens). + `analysis/headless-limits.json` lists them with the reason each fails (`headlessOnly`), beside + the engine error lines only a headless process prints (`headlessEngineErrors`); the script + reads them as `$HeadlessOnly` and `$HeadlessEngineErrors`, and `RunCiSuites.ps1` reads the same + file. The same suites and the same error counts come out of the Windows export run headless, which is how an entry is admitted: a suite that fails on Linux alone is a Linux bug and never goes on the list. Listed suites still run, and one that passes is reported so a stale entry is seen. diff --git a/docs/verification.md b/docs/verification.md index 6e71ce387..41c982dfe 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -628,6 +628,10 @@ member, and it does not go here. world: an empty list is a library root, which the world build never creates and no lookup finds.** C1's `cs_flg_light1`, the carried flag, answered neither `AnimRuntime.FindNodes` nor a name walk of the world root while `nodes.json` held it, and `SceneBuilder.BuildSubtree` built it. +- **WORLD-51**, **A layering census over `nodes.json` counts geometry the session hides; before + reading "X lies under Y" as what draws, check it against a flat render with the suspect texture + overridden.** C3's survey put 9,500 cells of `p-1` cliff and sand tiles under a `p0` sea sheet + the flat render draws them over, and its `swtr01`-`05` sheets above the crater floor build hidden. ## SHELL, Windows, PowerShell, and processes diff --git a/sandbox/LinuxRelease.ps1 b/sandbox/LinuxRelease.ps1 index 9038355f0..4c74b7a14 100644 --- a/sandbox/LinuxRelease.ps1 +++ b/sandbox/LinuxRelease.ps1 @@ -71,41 +71,15 @@ $Inv = [Globalization.CultureInfo]::InvariantCulture $RepoRoot = Split-Path $PSScriptRoot -Parent $Utf8 = New-Object System.Text.UTF8Encoding($false) -# Suites that cannot pass in a headless process by construction, on any platform: each reads -# back something only a real renderer or a real display produces. Every entry was run headless -# from the Windows export of the same commit against a Windows extraction of the same shape and -# failed there too, so an entry here is never a Linux finding. They still run; a listed suite -# that passes is reported so a stale entry is seen. This is the one place the list lives. -$HeadlessOnly = [ordered]@{ - "build-stamp-focus" = "pushes a click at the logs icon's laid-out centre, which a headless window of no size places outside the viewport" - "cloud-field-fade" = "reads the cloud cards' normals back out of the built mesh, which the headless dummy renderer does not store" - "clutter-activation" = "reads each stamp's drawn state back from the clutter MultiMesh's instance transforms, which the headless renderer keeps no instances for" - "clutter-cells" = "reads every placement back through the clutter cells' MultiMesh instance transforms, which the headless renderer keeps no instances for" - "clutter-card-depth" = "counts rendered pixels in a viewport; headless renders none" - "display-mode" = "asserts on the window mode of a real window; the headless display server has none" - "display-monitor" = "enumerates screens; the headless display server reports none" - "display-resolution" = "reads the screen size; the headless display server reports 0x0" - "display-vsync" = "applies V-Sync to a real window's swap chain; headless has none" - "fog-per-view" = "reads each splitscreen pane's rendered fog colour back from its viewport; headless renders none" - "graphics-live-switch" = "reads the drawn clutter buffers and every texture's image back after each switch, which the headless renderer keeps neither of" - "menu-original-tracer" = "reads the OS pointer's visibility and the monitor list from the display server, which headless does not provide" - "menu-screenshot-key" = "captures the viewport texture to a PNG; headless draws nothing, so every file is empty" - "muzzle-flash-rides-muzzle" = "reads the flash quads' drawn instance transforms back, which the headless renderer does not keep" - "puffer-fire-glow" = "reads each emitter column's MultiMesh instance custom data back, which the headless renderer does not keep" - "puffer-smoke-sun" = "reads each emitter column's MultiMesh instance custom data back, which the headless renderer does not keep" - "sun-per-view" = "counts each splitscreen pane's rendered aircraft pixels and reads their shade back; headless renders none" - "trail-world-anchor" = "reads the first drawn puff's instance transform back, which the headless renderer does not keep" - "wing-flare-pose" = "counts the flare's rendered pixels in a viewport; headless renders none" -} -# Engine error lines a headless process prints and a windowed one does not, on either platform: -# the same run from the Windows export prints each of these, at the same counts. The harness's -# own allowlist is written for the windowed battery, so these are passed here, by pattern, and -# every other unexpected line still fails the stage. -$HeadlessEngineErrors = [ordered]@{ - 'texture_2d_get \(\./servers/rendering/dummy/' = "the dummy renderer holds no texture data to read back" - '(_shaped_text_add_string|_ensure_cache_for_size|_font_get_(ascent|descent)) \(modules/text_server_adv/' ="a headless font has no rasterised size cache, so shaping at a measured size of 0 fails" - 'The new image dimensions must match the texture size\. at: update \(scene/resources/image_texture\.cpp' = "a graphics switch re-uploads an alpha-depth texture in place (TextureUpload.Replace), and a headless process reports a size mismatch the windowed battery does not" -} +# The suites that cannot pass in a headless process by construction ($HeadlessOnly) and the +# engine error lines only a headless process prints ($HeadlessEngineErrors), each with its reason. +# They live in analysis/headless-limits.json, which RunCiSuites.ps1 reads too, so the two headless +# runs share one list; the file's readme says what admits an entry. +$headlessLimits = [IO.File]::ReadAllText((Join-Path $RepoRoot "analysis\headless-limits.json"), $Utf8) | ConvertFrom-Json +$HeadlessOnly = [ordered]@{} +foreach ($p in $headlessLimits.headlessOnly.PSObject.Properties) { $HeadlessOnly[$p.Name] = [string]$p.Value } +$HeadlessEngineErrors = [ordered]@{} +foreach ($p in $headlessLimits.headlessEngineErrors.PSObject.Properties) { $HeadlessEngineErrors[$p.Name] = [string]$p.Value } function Write-Banner([string]$Text) { Write-Host ""