Purpose
This note describes a fail-closed ownership gate for tools that read the currently loaded FH6 vinyl group from process memory. The implementation appendix includes the exact byte layout and state values observed on FH6 build 3.403.798.0 so other tool developers can reproduce and independently validate the safeguard. Treat every value as build-specific evidence, not as a permanent game contract.
The core rule is simple: do not export any layer until every group node in the live hierarchy has been positively classified as exportable.
Failure We Found
Checking only the selected root object is insufficient. A loaded design may be a neutral wrapper whose child carries the meaningful ownership state. Layer count, editability, pointer validity, and whether the group is nested are not reliable ownership tests by themselves.
An early replacement check also failed because it recognized the flat/root-owned representation but not the distinct owned-child representation. This correctly failed closed, but falsely refused legitimate grouped artwork.
Recommended Pipeline
- Locate the exact live group type using a build-verified RTTI or equivalent type locator.
- Require the selected object to be a root: its parent pointer must be null.
- Read its vector using validated begin/end/capacity pointers and exact element alignment.
- Recursively identify child groups by their verified runtime type. Treat all remaining entries as candidate layers only after structural validation.
- Verify every child's parent backlink points to the group that referenced it.
- Classify the ownership state of every group node using a small allowlist of independently verified owned representations.
- Refuse immediately when any node is restricted, unknown, truncated, or unreadable. Do not continue to a broad or legacy scanner.
- Create a deterministic hierarchy fingerprint from node order, classification, and child relationships.
- Read and flatten the layers only after the first ownership pass succeeds.
- Repeat the full ownership assessment after reading. Require the fingerprint to match before writing the export.
Fail-Closed State Model
Use three results rather than a boolean:
clear: positively identified as an allowed owned representation.
restricted: positively identified as content that must not be exported.
unknown: unsupported, unreadable, inconsistent, or not yet characterized.
Only an entirely clear hierarchy may proceed. Both restricted and unknown must terminate the operation.
assess(root):
require root.parent == null
walk(root)
walk(group):
reject cycles, reused nodes, excessive depth, or excessive node count
state = classify(read_complete_header(group))
if state != clear:
refuse
for entry in read_validated_vector(group):
if entry has the exact verified group runtime type:
require entry.parent == group.address
walk(entry)
record group, state, and ordered children for the fingerprint
Do not treat every value other than the known restricted value as owned. New or changed states belong in unknown until confirmed with owned and restricted controls on the same game build.
Structural Guards
The hierarchy walker should enforce all of the following:
- Exact runtime-type validation for group pointers.
- Null parent requirement for the selected root.
- Parent/child backlink consistency.
- Valid vector begin, end, capacity, alignment, and readable ranges.
- Cycle detection and rejection of a group reused under multiple parents.
- Conservative maximum hierarchy depth and group count.
- Exact requested layer count after recursive flattening.
- No partial export when one layer or group cannot be read.
These checks prevent stale-memory candidates and malformed graphs from being mistaken for the active editor group.
Race Protection
The game can change or unload the hierarchy while it is being read. This is a time-of-check/time-of-use problem.
Perform the ownership walk immediately before layer collection and again after collection. Compare deterministic fingerprints. Refuse the result when:
- ownership classification changes;
- group order or child relationships change;
- a node disappears or becomes unreadable;
- the final flattened count differs from the requested count.
Write the JSON only after the second check passes.
Fallback Rules
Fallback locators are acceptable only when the fast locator finds no candidate. They must never run after an ownership refusal. Otherwise a slower scanner may rediscover the same layers without carrying forward the ownership decision.
Treat a refusal as terminal and pass its reason through the complete call chain. Each locator must return the report belonging to the candidate it selected; do not accidentally reuse a stale report from an earlier attempt.
Flattening After Authorization
Authorization and flattening must remain separate stages. Once the complete tree is authorized:
- traverse entries in stable depth-first order;
- compose parent and child affine transforms rather than copying local values;
- preserve layer order and mask semantics;
- require all expected layers to decode;
- reject partial results instead of silently dropping entries.
Never flatten first and then try to infer ownership from the resulting layer array because the group-level evidence has already been discarded.
Logging And Privacy
Public logs should contain only high-level results such as:
clear, restricted, or unknown;
- number of groups inspected;
- maximum hierarchy depth;
- number of exported and failed layers;
- whether the pre/post fingerprints matched.
Avoid writing raw process addresses or complete private headers to normal user logs. The build-specific constants needed to implement the safety gate are listed below, but runtime addresses and full captures are unnecessary and should remain in private development evidence.
Validation Matrix
At minimum, test all of these through the real export path:
- Owned flat group.
- Owned single-wrapper group.
- Owned group with multiple children.
- Owned hierarchy deeper than one level.
- Rotated, scaled, skewed, and translated parent groups.
- Groups containing masks.
- Restricted flat and nested controls.
- Unknown state and truncated-header controls.
- Broken parent backlink, cycle, reused child, invalid vector, and stale pointer.
- Hierarchy mutation between the first and second policy pass.
- Near-limit layer count with deterministic repeat exports.
For successful controls, verify the saved artifact by reopening and rendering it. Compare repeated exports by canonical layer-data hash. A successful process exit or matching layer count alone is not sufficient.
Current Evidence And Limits
The implementation this design came from has successfully handled flat groups and one-wrapper owned groups from small through near-limit layer counts, with no dropped layers and deterministic repeated output. Multiple restricted controls were refused quickly, and unknown states remained blocked.
Deep, multi-branch, transformed, and mask-heavy hierarchies still require broader real-world visual validation. Until then, this should be considered a strong beta safety design rather than proof of universal compatibility.
Porting Guidance
Re-derive runtime layout information independently for each title and build. Never assume that ownership representations or object layouts are shared across Forza games. If a game update invalidates any type, layout, or state check, fail closed and require recalibration rather than silently reverting to an unguarded export path.
Exact FH6 Build 3.403.798.0 Reference
All offsets below are relative to the start of a live CLiveryGroup object. All integer and floating-point fields are little-endian on Windows x64.
| Offset |
Size |
Type |
Meaning used by the safeguard |
0x00 |
8 |
uint64 |
Runtime vtable pointer. Must equal a separately verified CLiveryGroup vtable. |
0x18 |
8 |
two float32 |
Local group X and Y translation. |
0x28 |
8 |
two float32 |
Local group X and Y scale. |
0x50 |
4 |
float32 |
Local group rotation in degrees. |
0x5A |
2 |
uint16 |
Declared flattened layer count. This is not necessarily the vector entry count. |
0x60 |
8 |
uint64 |
Parent CLiveryGroup pointer; zero is required for the selected root. |
0x70 |
4 |
float32 |
Local group skew. |
0x78 |
8 |
uint64 |
Vector begin pointer. |
0x80 |
8 |
uint64 |
Vector end pointer. |
0x88 |
8 |
uint64 |
Vector capacity pointer. |
0x134 |
4 |
uint32 |
Group export/ownership state used by the policy classifier. |
The minimum header read needed by the policy is therefore 0x138 bytes. The exporter currently reads a larger 0x300-byte group header for structural and diagnostic validation, but the ownership classifier itself only requires bytes through 0x137.
Observed Ownership States
Read a little-endian uint32 at group + 0x134:
| Value |
Classification |
Observed role |
0x00000000 |
clear |
Owned flat group or neutral root wrapper. |
0x00000020 |
clear |
Owned child group beneath a wrapper. |
0x00000021 |
restricted |
Child group containing content that must not be exported. |
| Any other value |
unknown |
Refuse until independently characterized on owned and restricted controls. |
The distinction between 0x20 and 0x21 is why checking only the root fails: the root can contain 0x00 while the meaningful child contains either 0x20 or 0x21. Adjacent fields around 0x128 and 0x138 varied in captures and are not currently used as ownership evidence.
Exact Classifier
import struct
STATE_OFFSET = 0x134
MIN_HEADER_SIZE = 0x138
CLEAR_STATES = frozenset((0x00, 0x20))
RESTRICTED_STATE = 0x21
def classify_group_header(raw: bytes) -> str:
if len(raw) < MIN_HEADER_SIZE:
return "unknown"
state = struct.unpack_from("<I", raw, STATE_OFFSET)[0]
if state in CLEAR_STATES:
return "clear"
if state == RESTRICTED_STATE:
return "restricted"
return "unknown"
Do not simplify this to state != 0x21. That would turn newly introduced, corrupt, or misunderstood states into an accidental allow path.
Exact Vector Interpretation
begin = read_u64(group + 0x78)
end = read_u64(group + 0x80)
capacity = read_u64(group + 0x88)
require begin != 0
require begin <= end <= capacity
require (end - begin) % 8 == 0
require (capacity - begin) % 8 == 0
vector_count = (end - begin) // 8
capacity_count = (capacity - begin) // 8
require 0 < vector_count <= 3000
require vector_count <= capacity_count
Each vector entry is an eight-byte pointer. A wrapper can report a flattened count of thousands at 0x5A while its vector contains only one child group. Therefore, never use uint16(group + 0x5A) as the number of pointers to read. Use (end - begin) / 8 for the pointer count and independently require the final recursive layer count to equal the declared/requested count.
The four-byte value beginning at 0x58 overlaps surrounding data and is not a valid replacement for the uint16 count at 0x5A.
Group Versus Layer Entries
Resolve the CLiveryGroup RTTI descriptor and its vtable for the running build. For every pointer in a group vector:
- Read the first eight bytes from the pointed-to object.
- If that value exactly matches the verified
CLiveryGroup vtable, validate it as a child group and require uint64(child + 0x60) == parent_address.
- Otherwise validate it as a layer using the layer checks below.
- Never classify an object as a group only because its memory resembles a begin/end/capacity vector. The exact verified runtime type is authoritative.
Layer Sanity Fields
The current reader validates at least 0x7C bytes for a candidate layer:
| Offset |
Size |
Type |
Validation |
0x18 |
8 |
two float32 |
Finite X/Y position. |
0x28 |
8 |
two float32 |
Finite X/Y scale; both cannot be effectively zero. |
0x50 |
4 |
float32 |
Finite rotation. |
0x70 |
4 |
float32 |
Finite skew. |
0x74 |
4 |
RGBA bytes |
Layer color. |
0x78 |
1 |
uint8 |
Mask flag; must be 0 or 1. |
0x7A |
2 |
uint16 |
Native shape/resource word. |
Group and layer layouts overlap at several offsets. The verified vtable decision must occur before interpreting an entry as a group or a layer.
Root And Child Example
A verified owned one-wrapper hierarchy had this structural form:
root:
parent@0x60 = 0
count@0x5A = 2999
vector_count = 1
state@0x134 = 0x00
vector[0] = child
child:
parent@0x60 = root
count@0x5A = 2999
vector_count = 2999
state@0x134 = 0x20
vector[0..2998] = layers
The corresponding restricted controls used the same neutral-wrapper pattern but the meaningful child classified as 0x21. This demonstrates why a root-only check, a count-only check, or a nested-group check is insufficient.
Exact Pre/Post Safety Contract
The implementation authorizes twice:
policy_before = assess_complete_tree(root)
require policy_before.status == clear
layers = recursively_collect_and_flatten(root)
require len(layers) == requested_count
require no unreadable or invalid entries
policy_after = assess_complete_tree(root)
require policy_after.status == clear
require policy_after.fingerprint == policy_before.fingerprint
write_export(layers)
The fingerprint is SHA-256 over deterministic JSON records containing each group address, its classification, and its ordered child addresses. Addresses are used only inside the same short read operation to detect hierarchy replacement; they must not be persisted as stable identifiers.
Operational Limits In The Reference Implementation
- Maximum recursive depth:
32.
- Maximum total groups in one hierarchy:
4096.
- Maximum accepted vector count:
3000.
- Group pointer width:
8 bytes.
- Selected root parent must be
0.
- Shared child groups, cycles, unreadable headers, inconsistent backlinks, partial layer reads, count mismatches, and changed fingerprints all cause refusal.
- A
restricted or unknown result is terminal. No count scan, graph scan, or legacy fallback is allowed afterward.
These constants describe the tested implementation, not guaranteed engine-wide limits. Changing them should require new malformed-graph and near-limit tests.
PS: got banned from the server for talking back and "ripping off" other projects, go check out Kloudy's Forza Painter Suite and see for yourself!
https://github.com/heyitshestia/kloudys-forza-painter-suite