Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) an

## [Unreleased]

## [0.5.9] - 2026-10-10

### Fixed

- Name pools are found when their header stores nonzero values in a field that most saves leave at zero. Every other header byte, each pool entry and the uniqueness check are still verified.
- The stage table is found however far it sits from the end of `game_db`. Longer careers move it further back, which made stages, competitions, fixtures, league tables, competition rules, match stats, stadiums and job vacancies fail.
- Tactics validation accepts managers who also run national teams, whose tactic blocks the section stores alongside the club's.
- Newly generated players stored with object kind 3, and newly generated staff whose ability codes are outside the observed code set, are read instead of being dropped.

Refs #20.

## [0.5.8] - 2026-10-07

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fmsave"
version = "0.5.8"
version = "0.5.9"
description = "Read Football Manager 26 save files into Python records, DataFrames, CSV and JSON."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
12 changes: 9 additions & 3 deletions src/fmsave/_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2092,8 +2092,10 @@ def evaluate_tactics(
`tactics_team_blocks_match_club` carries the header's own claim as well as the blocks
found, because the two fail differently: a team-id locator that has moved leaves a team
without its single block, while a header read from the wrong offset claims a number of
blocks that is not the managed club's team count at all. A header that disagrees scores
zero here whatever the locator found.
blocks that is not the number the section stores at all. The header counts every team the
manager runs, national teams included, so it is compared with the blocks found by
structure and must cover at least the club's teams. A header that disagrees scores zero
here whatever the locator found.

The record-count share checks the team's declared count independently.
The two walk shares are judged on all stored tactic records. They are excused when the
Expand All @@ -2104,7 +2106,10 @@ def evaluate_tactics(
"""
applied = _applies(bounds, game_db_bytes) and stats.managed_club_exists
slots_applied = applied and (stats.tactic_blocks > 0 or stats.stored_tactics > 0)
blocks_found = stats.blocks_found if stats.header_blocks == stats.club_team_count else 0
header_agrees = (
stats.header_blocks == stats.stored_blocks and stats.header_blocks >= stats.club_team_count
)
blocks_found = stats.blocks_found if header_agrees else 0
return (
_gate(
"tactics_manager_selector_matches",
Expand Down Expand Up @@ -2189,6 +2194,7 @@ def check_tactics(stats: TacticStats, bounds: GateBounds, game_db_bytes: int) ->
FrozenMapping(
{
"preset_tactics": stats.preset_tactics,
"non_club_blocks": stats.stored_blocks - stats.blocks_found,
"tactic_blocks_count_mismatched": (
stats.tactic_blocks - stats.tactic_blocks_count_matching
),
Expand Down
21 changes: 12 additions & 9 deletions src/fmsave/_layouts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,17 @@ class NamePoolLayout:
"""How to find and check the three name pools in `game_db`.

`signature` sits immediately before the first pool's entry count. Its three capacity
words may scale together up to `max_signature_scale`. Pools have no minimum size,
since databases differ in size; every entry's id and length are checked instead.
`max_name_bytes` bounds each name's byte length independently of section bounds;
words may scale together up to `max_signature_scale`. The bytes at
`unchecked_signature_bytes` may hold any value; they are read as zero. Pools have no
minimum size, since databases differ in size; every entry's id and length are checked
instead. `max_name_bytes` bounds each name's byte length independently of section bounds;
it is a parser safety ceiling, not a verified limit on names stored by the game.
"""

signature: bytes
max_name_bytes: int
max_signature_scale: int = 1
unchecked_signature_bytes: tuple[int, ...] = ()


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -375,7 +377,7 @@ class PlayerRecordLayout:
rejected one.

Ordinary identity words match. In the allocated namespace starting at
`allocated_uid_minimum`, a distinct second word also requires `player_kind` at
`allocated_uid_minimum`, a distinct second word also requires one of `player_kinds` at
`object_kind_offset`, together with every ability, reputation and attribute check.
The distinct word's meaning is not inferred.

Expand All @@ -391,7 +393,7 @@ class PlayerRecordLayout:
uid_copy_offset: int
allocated_uid_minimum: int
object_kind_offset: int
player_kind: int
player_kinds: tuple[int, ...]
home_reputation_offset: int
current_reputation_offset: int
world_reputation_offset: int
Expand Down Expand Up @@ -1124,9 +1126,10 @@ class StageTableLayout:

The table is found in the last `search_bytes` of `game_db`, at the first offset where
`chain_rows` rows in a row all decode `row_bytes` apart. When none does, the window doubles
until it reaches `max_search_bytes`, searching only the stretch each doubling adds, so a
save whose table sits near the end pays for the first window alone. The head of that chain
is then reached by stepping back a row at a time. A row decodes when the byte at
until it reaches `max_search_bytes`, or the start of `game_db` when that is None,
searching only the stretch each doubling adds, so a save whose table sits near the end
pays for the first window alone. The head of that chain is then reached by stepping back
a row at a time. A row decodes when the byte at
`zero_byte_offset` is zero, the stage id at `stage_id_offset` is repeated at
`stage_id_copy_offset` and lies strictly between the bounds of
`stage_id_exclusive_range`, and the word at `previous_stage_id_offset` is either that id
Expand All @@ -1151,7 +1154,7 @@ class StageTableLayout:
stage_id_exclusive_range: tuple[int, int]
competition_id_limit: int
search_bytes: int
max_search_bytes: int
max_search_bytes: int | None
chain_rows: int
resynchronisation_bytes: int

Expand Down
29 changes: 17 additions & 12 deletions src/fmsave/_layouts/fm26_26_3_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@
# original sample-based ceiling of 64 bytes (Refs #14).
max_name_bytes=1024,
max_signature_scale=1024,
# Zero in most saves; one 26.3.2 career save stores 03 03 03 03 here (Refs #20).
unchecked_signature_bytes=(5, 6, 7, 8),
)

CLUB_RECORDS = ClubRecordLayout(
Expand Down Expand Up @@ -328,7 +330,9 @@
# and the complete rating/attribute structure must still match a player.
allocated_uid_minimum=2_000_000_000,
object_kind_offset=-7,
player_kind=2,
# Ordinary player records carry kind 2 or 3 (96,392 of 5.2 million across the corpus are
# 3), so an allocated player can carry either (Refs #20).
player_kinds=(2, 3),
home_reputation_offset=-6,
current_reputation_offset=-4,
world_reputation_offset=-2,
Expand Down Expand Up @@ -1011,10 +1015,10 @@
)

# A stage row is 33 bytes. The table starts 0.65 to 0.94 MB from the end of `game_db` on the
# corpus saves, so the last 2 MB is searched first; one community career started on an older
# build has 4.1 MB after its table. Another continued career has 20.7 MB after the table, so
# the window may double to 32 MiB before the table counts as missing. 200 rows is far longer
# than any run of look-alike bytes seen before it.
# corpus saves, so the last 2 MB is searched first. What follows the table grows as a career
# goes on (4.1, 20.7 and 34.2 MB on longer careers, Refs #5 and #20), so no fixed window
# holds every save: the window keeps doubling to the start of `game_db`. 200 rows is far
# longer than any run of look-alike bytes seen before it, and the stage gates check the rows.
STAGE_TABLE = StageTableLayout(
row_bytes=33,
previous_stage_id_offset=0,
Expand All @@ -1031,7 +1035,7 @@
# competitions a save holds; this limit rejects it without touching a real competition.
competition_id_limit=1_000_000,
search_bytes=2_000_000,
max_search_bytes=32 * 1024 * 1024,
max_search_bytes=None,
chain_rows=200,
resynchronisation_bytes=4_096,
)
Expand Down Expand Up @@ -1276,9 +1280,9 @@
# The last word is the missing value on about 98.4% of rows and carries a small number on
# the rest, so this is a shape check rather than a sentinel.
stage_trailing_sentinel=(0.95, None),
# As far back as the stage table search reaches (see STAGE_TABLE): 0.63 to 0.92 MiB on the
# corpus saves, 3.94 MiB on one older-build career, and 20.7 MB on another.
stage_table_tail_bytes=(None, 32 * 1024 * 1024),
# Reported, not bounded: the bytes after the table grow with the career (see STAGE_TABLE),
# and the other stage gates judge the rows themselves.
stage_table_tail_bytes=(None, None),
competitions_minimum=(50, None),
# The id-pair records name 91.47% to 91.52% of the stage table's competitions across two
# careers and a live save, and no save leaves a single competition in conflict. The share
Expand Down Expand Up @@ -1573,9 +1577,10 @@
# byte or four bytes late it names nobody at all, so only equality passes.
tactics_manager_selector_matches=(1, 1),
# Every team of the managed club has exactly one block on every save measured, and the
# header claims that same number. Reading the count four bytes late gives a
# team id instead, and one byte late gives 1,610,612,736, so the header agreement fails on
# its own; a team-id locator read one byte late matches more places than there are teams
# header claims as many blocks as the section stores: the club's teams, plus any national
# teams the manager also runs (7 on one career, Refs #20). Reading the count four bytes
# late gives a team id instead, and one byte late gives 1,610,612,736, so the header
# agreement fails on its own; a team-id locator read one byte late matches more places than there are teams
# over the same team ids, which leaves a team without its single block.
tactics_team_blocks_match_club=(1.0, None),
# Every user tactic record walks its 22 slot blocks, and the eleven
Expand Down
7 changes: 5 additions & 2 deletions src/fmsave/_reader_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,10 @@ class TacticStats:
read and both tables are empty.

`club_team_count` is how many teams the managed club fields, `header_blocks` the number of
blocks the section header claims and `blocks_found` how many teams the section stores
exactly one block for. `selector_matches` is whether the section header names the same
blocks the section header claims, `stored_blocks` how many blocks the section holds when
found by structure (national teams the manager also runs included; 0 when the first is
not where the header ends) and `blocks_found` how many teams the section stores exactly
one block for. `selector_matches` is whether the section header names the same
human manager as the `humans` section.

`selection_selectors` counts every player selector the blocks store, over the selection
Expand All @@ -775,6 +777,7 @@ class TacticStats:
managed_club_exists: bool
club_team_count: int
header_blocks: int
stored_blocks: int
blocks_found: int
selector_matches: bool
selection_selectors: int
Expand Down
1 change: 1 addition & 0 deletions src/fmsave/_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ class _FinanceTables(NamedTuple):
managed_club_exists=False,
club_team_count=0,
header_blocks=0,
stored_blocks=0,
blocks_found=0,
selector_matches=False,
selection_selectors=0,
Expand Down
14 changes: 10 additions & 4 deletions src/fmsave/readers/names.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,32 @@ def locate_name_pools(game_db: bytes, layout: NamePoolLayout, file_name: str) ->


def _signature_candidates(game_db: bytes, layout: NamePoolLayout) -> Iterator[int]:
"""Capacity words can grow together; every candidate still needs three checked pools."""
"""Capacity words can grow together and unchecked bytes may vary; every candidate still
needs three checked pools."""
signature = layout.signature
if layout.max_signature_scale == 1:
unchecked = layout.unchecked_signature_bytes
if layout.max_signature_scale == 1 and not unchecked:
start = 0
while (at := game_db.find(signature, start)) >= 0:
yield at
start = at + 1
return
words = struct.unpack("<6I", signature)
prefix = signature[:8]
prefix = signature[: min((8, *unchecked))]
start = 0
while (at := game_db.find(prefix, start)) >= 0:
start = at + 1
if at + len(signature) > len(game_db):
continue
stored = struct.unpack_from("<6I", game_db, at)
stored_bytes = bytearray(game_db[at : at + len(signature)])
for byte_at in unchecked:
stored_bytes[byte_at] = 0
stored = struct.unpack("<6I", stored_bytes)
scale, remainder = divmod(stored[3], words[3])
if (
remainder == 0
and 1 <= scale <= layout.max_signature_scale
and stored[:2] == words[:2]
and stored[2:5] == tuple(value * scale for value in words[2:5])
and stored[5] == words[5]
):
Expand Down
4 changes: 2 additions & 2 deletions src/fmsave/readers/player_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ def _accept_candidate(
return None
if uid != uid_copy and (
uid < layout.allocated_uid_minimum
or game_db[record_offset + layout.object_kind_offset] != layout.player_kind
or game_db[record_offset + layout.object_kind_offset] not in layout.player_kinds
):
return None
header_values = header_layout.struct_object.unpack_from(game_db, header_absolute_start)
Expand All @@ -384,7 +384,7 @@ def _accept_candidate(
layout.edited_ability_maximum is None
or layout.edited_attribute_maximum is None
or uid != uid_copy
or game_db[record_offset + layout.object_kind_offset] != layout.player_kind
or game_db[record_offset + layout.object_kind_offset] not in layout.player_kinds
):
return None
highest_ability = layout.edited_ability_maximum
Expand Down
6 changes: 4 additions & 2 deletions src/fmsave/readers/staff.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,10 @@ def _header_values(
if uid != other:
if layout is None or uid < layout.allocated_uid_minimum or kind != layout.staff_kind:
return None
block, slots_ok, codes_ok, further_ok = _read_ability_block(game_db, header, layout)
if block is None or not (slots_ok and codes_ok and further_ok):
# The code set is the codes observed, not a closed set (`staff_codes_in_set` is a
# share for that reason), so a code outside it cannot rule out a header (Refs #20).
block, slots_ok, _codes_ok, further_ok = _read_ability_block(game_db, header, layout)
if block is None or not (slots_ok and further_ok):
return None
return uid, kind

Expand Down
23 changes: 14 additions & 9 deletions src/fmsave/readers/stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class _StageTableScan:
row_bytes: int
chain_rows: int
search_bytes: int
max_search_bytes: int
max_search_bytes: int | None
resynchronisation_bytes: int
lowest_stage_id: int
highest_stage_id: int
Expand Down Expand Up @@ -140,7 +140,7 @@ def _stage_table_scan(layout: StageTableLayout) -> _StageTableScan:
raise ValueError(f"chain_rows {layout.chain_rows} must be at least 1")
if layout.competition_id_limit < 1:
raise ValueError(f"competition_id_limit {layout.competition_id_limit} must be at least 1")
if layout.max_search_bytes < layout.search_bytes:
if layout.max_search_bytes is not None and layout.max_search_bytes < layout.search_bytes:
raise ValueError(
f"max_search_bytes {layout.max_search_bytes} is narrower than search_bytes "
f"{layout.search_bytes}"
Expand Down Expand Up @@ -219,8 +219,9 @@ def find_table_start(game_db: bytes, scan: _StageTableScan) -> int | None:

The search covers the last `search_bytes` of `game_db`, since the table sits at the end of
the section. When no chain starts there, the window doubles until it reaches
`max_search_bytes`, and each doubling searches only the stretch it adds, so a save whose
table sits near the end pays for the first window alone and no offset is tried twice.
`max_search_bytes`, or the start of `game_db` when that is None, and each doubling
searches only the stretch it adds, so a save whose table sits near the end pays for the
first window alone and no offset is tried twice.

The first offset that starts a full chain is found one byte at a time, and the head is then
reached by stepping back while the row before still decodes, so rows before the chain are
Expand All @@ -239,10 +240,12 @@ def find_table_start(game_db: bytes, scan: _StageTableScan) -> int | None:
table_start = _find_table_start_between(game_db, scan, window_start, searched_from)
if table_start is not None:
return table_start
if window_bytes >= scan.max_search_bytes:
if scan.max_search_bytes is not None and window_bytes >= scan.max_search_bytes:
return None
searched_from = window_start
window_bytes = min(window_bytes * 2, scan.max_search_bytes)
window_bytes *= 2
if scan.max_search_bytes is not None:
window_bytes = min(window_bytes, scan.max_search_bytes)
return None


Expand Down Expand Up @@ -330,10 +333,12 @@ def read_stage_index(game_db: bytes, layout: StageTableLayout, file_name: str) -
scan = _stage_table_scan(layout)
table_start = find_table_start(game_db, scan)
if table_start is None:
raise layout_mismatch(
file_name,
f"no stage table was found in the last {scan.max_search_bytes:,} bytes of game_db",
searched = (
"game_db"
if scan.max_search_bytes is None
else f"the last {scan.max_search_bytes:,} bytes of game_db"
)
raise layout_mismatch(file_name, f"no stage table was found in {searched}")
walked = _walk_rows(game_db, table_start, scan)

stages: list[Stage] = []
Expand Down
Loading
Loading