Skip to content

v1.2 (experimental) save support + comprehensive test suite#15

Open
ChrisonSimtian wants to merge 12 commits into
R3dByt3:mainfrom
ChrisonSimtian:fix/v1.2-toc-data-blob
Open

v1.2 (experimental) save support + comprehensive test suite#15
ChrisonSimtian wants to merge 12 commits into
R3dByt3:mainfrom
ChrisonSimtian:fix/v1.2-toc-data-blob

Conversation

@ChrisonSimtian

Copy link
Copy Markdown

Summary

  • Adds parsing support for Satisfactory v1.2 (experimental branch) — FSaveObjectVersionData block, new property tag format with serializationControl byte, optional per-object GUID, v1.2 ExtraData branches for Conveyor / PowerLine / CircuitSubsystem.
  • 134 unit + integration tests covering both v1.2 and the pre-v1.2 (v1.1 stable branch) code paths. Lifts SatisfactorySaveNet.dll sequence coverage from effectively 0% to ~63%.
  • 3 small curated .sav fixtures committed for end-to-end regression testing, plus a Fixtures/.gitignore that keeps personal autosaves out of git while still smoke-testing them locally.
  • Update-Fixtures.ps1 helper to pull matching saves from %LOCALAPPDATA%\FactoryGame\Saved\SaveGames\… into the test project.
  • TODO.md at the repo root tracking follow-ups — most importantly an architectural sketch for separating vanilla and mod-specific types (FicsItNetworks, FicsItFarming, etc.) into their own packages.

Happy to split into smaller PRs if that's easier — the test suite + fixtures are independent of the v1.2 parser work and can land separately.

v1.1 ↔ v1.2 compatibility

The v1.2 work is gated behind SaveCustomVersion >= 53 everywhere it diverges. Three layers prove v1.1 still parses cleanly:

  • Serializers/PropertySerializerLegacyTests — every typed Deserialize<Type>Property branch at saveVersion = 50
  • Serializers/ExtraDataSerializerLegacyTests — every pre-v1.2 ExtraData branch (Conveyor / PowerLine / Circuit / Vehicle / Locomotive / Blueprint / PlayerData / UnknownExtraData fallback / etc.)
  • Compat/VersionCompatibilityTests — cross-version dispatch tests pinning the legacy-vs-RawProperty switch, the ObjectHeader Flags gate at v51, the TypedData double-vs-float gate at v41, the CircuitData leading-count gate at v53

Deliberate-break sanity check: flipping >= 53> 53 in PropertySerializer.cs:60 fails at least one test in the compatibility suite.

Production-code changes

  • SatisfactorySaveNet/SatisfactorySaveNet.csprojInternalsVisibleTo("SatisfactorySaveNet.Tests")
  • SatisfactorySaveNet/ObjectSerializer.csReadOptionalObjectGuid and ReadOptionalPostBodyVersionData promoted from private to internal so the helpers can be unit-tested directly
  • New SatisfactorySaveNet/FSaveObjectVersionDataSerializer.cs (entirely new for v1.2)
  • v1.2 branches added in PropertySerializer.cs, ObjectSerializer.cs, BodySerializer.cs, SaveFileSerializer.cs, ExtraDataSerializer.cs

Known issue pinned by regression test

PropertySerializer.cs:117 gates TryParseKnownValue behind if (binarySize > 0), but the BoolProperty case inside that method documents itself as having no value bytes — the boolean lives in flag bit 0x10. If a save emits BoolProperty with binarySize == 0, RawProperty.BoolValue stays null. Pinned by DeserializeProperty_AtV12_BoolProperty_WithZeroBinarySize_LeavesBoolValueNull as a regression marker — happy to fix if you want the value populated from the flag bit even when binarySize == 0. See item 3 in TODO.md.

Test plan

  • dotnet build SatisfactorySaveNet.sln — clean, zero warnings
  • dotnet test SatisfactorySaveNet.sln — 134/134 pass (10s wall time)
  • dotnet test … --collect:"XPlat Code Coverage" --settings coverlet.runsettings.xml — module aggregate ~63% sequence
  • Deliberate-break check on a v1.2 version gate fails a compat test as expected

🤖 Generated with Claude Code

ChrisonSimtian and others added 12 commits May 11, 2026 14:06
Progresses save-version-60 parsing far enough to reach the property layer
but stops short of full support. v1.0 (SaveVersion 46) is unaffected.

What this commit lands:
- FSaveObjectVersionData model + serializer + nested types
  (FPackageFileVersion, FEngineVersion, FCustomVersion[Container]).
- ComponentObject.ObjectVersionData property.
- SaveFileSerializer: read save-level FSaveObjectVersionData
  immediately after the body envelope at header.SaveVersion >= 53.
  This was the missing first read that previously caused the body
  parse to mis-align at the grid block.
- BodySerializer: pass per-level saveCustomVersion through to
  ObjectSerializer, and read an optional level-wide
  FSaveObjectVersionData after the collectables list for
  non-persistent levels at >= 53.
- ObjectSerializer: accept a saveVersion parameter on Deserialize
  and read an optional post-body FSaveObjectVersionData per object,
  keyed on the per-object saveCustomVersion captured at >= 41.
- PropertySerializer: improve the unknown-property-type exception
  message to include the offending name + stream position.

What's still missing (the real wall):
- FPropertyTag in v1.2 changes shape: when
  saveVersion.object >= 53 AND packageFileVersionUE5 >=
  PROPERTY_TAG_COMPLETE_TYPE_NAME, every property tag is now a
  nested FPropertyTagNode with a flags byte plus optional
  index/GUID, and SaveObject.ParseData reads a one-byte
  'serializationControl' before the property list.
- Reaching that branch requires porting FPropertyTagNode, the
  IsCompletePropertyTagType gate, and per-property-type readers
  that consume the new tag shape. This is a much larger refactor
  than the original diagnostic on issue #31 estimated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the FSaveObjectVersionData groundwork and now parses Chris's
current v1.2 save (header SaveVersion 60, BuildVersion 489969):

  Levels: 302, Total objects: 7,218
  19 Build_MinerMk1_C, 20 Build_SmelterMk1_C, 43 Constructors, 4 Assemblers,
  459 BP_ResourceNode_C, 242 Mk1 + 173 Mk2 belts.

v1.0 (SaveVersion 46) unchanged: 241 levels, 2,278 objects, 4 miners.

Strategy: minimal viable port — read enough of the new layout to keep
the stream aligned through every object, defer deep per-type parsing
of properties and class-specific extra data to follow-ups.

Lands:
- FPropertyTagNode model (recursive type tree used by the v1.2
  complete-property-tag format).
- RawProperty (placeholder Property covering the new tag's metadata
  plus an opaque value blob; PropertyConstraint.Raw enum added).
- IPropertySerializer.DeserializeProperties/Property accept a
  saveVersion parameter; PropertySerializer threads it through.
- PropertySerializer reads the v1.2 tag (FPropertyTagNode + binarySize
  + flags + optional index/GUID) and skips binarySize bytes for the
  value, returning a RawProperty so callers stay aligned.
- PropertySerializer.DeserializeProperties consumes the leading
  serializationControl byte that the v1.2 SaveObject.ParseData writes
  before the property list.
- ObjectSerializer threads saveVersion into the property reader and
  reads the post-properties hasGuid+GUID pair at v1.2 before extra
  data.
- ObjectSerializer skips the class-specific ExtraData branch at v1.2
  — Conveyor/PowerLine/Circuit/etc. layouts diverged; the missing-
  bytes handler at the end of the body parse absorbs the remainder
  and keeps the stream lined up to the next object. Per-class v1.2
  ports come later.
- ExtraDataSerializer.DeserializeCircuitData: v1.2 dropped the leading
  Int32 (it now matches etothepii's CircuitSpecialProperties.Parse —
  single count + records). Version-gated on header.SaveVersion < 53.

Known limitations (intentional for this commit):
- Properties on v1.2 objects are RawProperty (no value). Per-type
  readers in the new format are TODO.
- ExtraData (Conveyor, PowerLine, Circuit, Drone, Vehicle, …) on v1.2
  is unread. Per-class v1.2 layouts are TODO.
- Component-header decode at v1.2 may be slightly off: etothepii reports
  ~1.8k FGFactoryConnectionComponent on this save where the fork shows
  zero. Total-object delta is ~1–2k. Doesn't affect actor counts or
  positions (which are what miner/smelter/etc. inventories actually need).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, StrProperty

Lifts the C# fork from "counts only" to full feature parity with
@etothepii/satisfactory-file-parser on the metrics that matter to the planner:
per-miner mined resource, per-producer recipe, per-generator fuel.

Parity confirmed against the Node reference on the same v1.2 save —
every count matches exactly:

  Miners by resource:   1 Coal, 3 OreCopper, 12 OreIron, 3 Stone
  Smelters by recipe:  16 IngotIron, 4 IngotCopper
  Constructors:         14 IronRod, 9 Screw, 8 IronPlate, 4 Concrete,
                         2 Wire, 2 Biofuel, 1 Cable, 1 Biomass_Wood,
                         1 Biomass_Leaves, 1 CopperSheet
  Coal generators:      6 on Coal
  Biomass burners:     12 on Biofuel
  Water extractors:     3

Approach:

- Targeted, not exhaustive. RawProperty (the v1.2 placeholder Property we
  already produce) gains three optional value slots:
    * ObjectValue           (ObjectReferenceValue, for ObjectProperty)
    * ArrayObjectValues     (IReadOnlyList<ObjectReferenceValue>, for
                             ArrayProperty whose element is ObjectProperty)
    * StringValue           (string, for StrProperty / NameProperty)
  Every other property type continues to round-trip as an opaque
  RawProperty — the planner doesn't need its value yet.

- New ObjectReferenceValue readonly record struct (LevelName, PathName) in
  the Properties namespace alongside RawProperty.

- PropertySerializer.TryParseKnownValue is the dispatcher. It runs after
  the FPropertyTag is consumed and before the binary-size fence. Each
  branch reads exactly the bytes its property type defines (per
  etothepii's ObjectProperty.Parse / ArrayProperty.Parse for the Object
  subtype / StrProperty.Parse).

- Hardening: the v1.2 reader now bounds every property value with
  `posBeforeValue + binarySize`. If the new value parser reads too few
  or too many bytes — or hits an unhandled type — the fence seeks the
  stream back to the expected position rather than throwing. Keeps
  parsing resilient to future property-type additions.

- Array elementCount sanity-capped at 1M; refuses to allocate hostile
  arrays and falls back to "skip and align".

Old code paths (header SaveVersion < 53) are untouched — v1.0 saves still
parse identically to before (241 levels, 2,278 objects, 4 miners on the
Finally 1.0 save).

Known gaps (intentional, follow-ups):

- StructProperty, MapProperty, SetProperty, IntProperty, FloatProperty,
  BoolProperty etc. still come back as RawProperty without value data.
  The planner needs none of these for stocktakes today.
- Class-specific ExtraData at v1.2 (ConveyorBelt segment paths,
  PowerLine endpoints, etc.) is still skipped.
- Component-header decode at v1.2 still under-counts component classes
  vs etothepii (~1k difference). Doesn't affect Actor counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-enables

Closes the remaining property-value gaps in the v1.2 path that the planner can
benefit from today, and re-enables the class-specific ExtraData branches whose
v1.2 format is a one-or-two-line port.

Property values now decoded in the v1.2 RawProperty path:
- All scalar Int*, UInt*, Float, Double properties (read exactly their
  binarySize bytes).
- BoolProperty (value is encoded in tag flag bit 0x10 — no value bytes).
- StructProperty for the fixed-size raw struct types: Vector, Rotator, Quat,
  Vector4(D), Vector2D, Box, LinearColor, Color, IntPoint, DateTime, Guid,
  TimerHandle, SlateBrush, FluidBox, RailroadTrackPosition. Unknown /
  property-list-shaped structs leave StructValue.Value null and the
  binary-size fence in the outer reader advances past the bytes.
- MapProperty when key AND value are both fixed-size primitives or framed
  strings (Int/Long/UInt/ULong/Byte/Bool/Float/Double/Str/Name/Enum/Object).
  Composite-typed maps (Struct/Array/Set/Map-of-Maps) leave MapEntries null.

Why conservative on Struct + Map: the first attempt at this work tried
DynamicStructPropertyValue (recursive property-list reads from inside a
StructProperty value) and similar handling for Map struct keys/values, plus a
try-catch-rewind fence to absorb missteps. The recursion was unbounded for
struct shapes we couldn't recognise and ended up either runaway-reading or
catching+rewinding millions of exceptions — the parse degraded from 1 s to
>2 min on the same save. The new implementation intentionally skips any
property type whose binary extent depends on data we can't see in the tag
alone, and trusts the binary-size fence to advance past it.

Class-specific ExtraData at v1.2:
- ConveyorBelt / ConveyorLift: v1.2 moved per-belt item storage onto the
  FGConveyorChainActor — per-belt ExtraData is now a single Int32 zero
  (matches etothepii's ConveyorSpecialProperties.Parse).
- PowerLine: v1.2 dropped the leading count Int32; ExtraData is now just
  source + target ObjectReferences (matches PowerLineSpecialProperties.Parse).
- CircuitSubsystem: already v1.2-correct from an earlier commit; the
  ObjectSerializer skip-list excluded it incorrectly — re-included now.
- ConveyorChainActor, Vehicle, Drone, Player, LightweightBuildableSubsystem:
  still skipped at v1.2. Their v1.2 layouts are complex enough to warrant a
  follow-up; the missing-bytes handler at the end of the body parse absorbs
  them today.

Verified on the v1.2 Beta Game save (SaveVersion 60, BuildVersion 489969):
  302 levels · 3690 actors · 7326 total objects · parsed in ~200 ms.
v1.0 save (SaveVersion 46) unchanged:
  241 levels · 1659 actors · 2278 total objects · 4 miners · 120 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Introduced a comprehensive test suite for the SatisfactorySaveNet serialization logic, focusing on version 1.2.
- Created a README.md file detailing the structure and purpose of the tests.
- Implemented BinaryReaderHelpers for synthesizing byte sequences in tests.
- Developed tests for FSaveObjectVersionDataSerializer, ObjectSerializer, PropertySerializer, and SaveFileSerializer.
- Ensured coverage for both unit tests with synthesized data and integration tests using real .sav file fixtures.
- Added assertions to validate deserialization behavior and stream consumption.
…Object

Captures the rationale, proposed API surface, and impact for adding
convenience accessors (TryGetEnumValue/TryGetFloatValue/etc.) on
ComponentObject so downstream adapters don't repeat property-walk
boilerplate for every actor type.

Tracked here rather than as a GitHub issue because issues are
disabled on this fork. Referenced from consuming-repo issue
erp-for-factory-games/ErpForFactoryGames#35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposes shipping a known-nodes dataset (coordinate-keyed) alongside
the parser so downstream consumers don't each have to maintain their
own. Resource type + purity are blueprint-class defaults — not in the
.sav — so this is the only sane path short of parsing .pak assets.

Referenced from the ERP planner's resource node identification work
(consumer-side seed lives in src/Satisfactory/Save/Data/ until this
lands upstream).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@denxorz

denxorz commented May 13, 2026

Copy link
Copy Markdown
Collaborator

I checked this with all the save games that I have gathered. This is around 35 games from some different players.
These are saves from 1.1 and down (into pre 1 versions). I see no regression, everything still seems fine.

The 1.2 seems to parse now.

@R3dByt3

R3dByt3 commented May 18, 2026

Copy link
Copy Markdown
Owner

I am currently short on time and this PR is a little bigger than expected. A careful review might take some hours and I am not sure when I will be able to finish it. @denxorz if you have a little time left and would like to - it would be very kind if you could lend me hand reviewing it based on my step by step guide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants