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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.2] - 2026-09-29

### Added

- `Club.unique_id` helps match clubs across separate careers. `Club.uid` still joins records within a save.

### Fixed

- `stages()` and its dependent readers can find stage tables further back in continued careers.

## [0.5.1] - 2026-09-28

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,7 @@ with fmsave.open("career.fm") as career_save:
injuries = career_save.injuries().where(club_uid=club_uid)
```

Use `Club.uid` within a save. To compare separate careers, use `Club.unique_id` when available.

[What a save holds](what-a-save-holds.md) lists all twenty-seven, and is honest about what none of
them can give you.
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.1"
version = "0.5.2"
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: 6 additions & 6 deletions src/fmsave/_layouts/fm26_26_3_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,9 +843,9 @@

# 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 (4.40 MB from the end to its start), so the window may double
# to 16 MB before the table counts as missing. 200 rows is far longer than any run of look-alike
# bytes seen before it.
# 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.
STAGE_TABLE = StageTableLayout(
row_bytes=33,
previous_stage_id_offset=0,
Expand All @@ -862,7 +862,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=16_000_000,
max_search_bytes=32 * 1024 * 1024,
chain_rows=200,
resynchronisation_bytes=4_096,
)
Expand Down Expand Up @@ -1103,8 +1103,8 @@
# 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 and 3.94 MiB on a career started on an older build.
stage_table_tail_bytes=(None, 16 * 1024 * 1024),
# corpus saves, 3.94 MiB on one older-build career, and 20.7 MB on another.
stage_table_tail_bytes=(None, 32 * 1024 * 1024),
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
8 changes: 7 additions & 1 deletion src/fmsave/models/clubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ class Club:
Club names are not unique: look clubs up by uid.

Attributes:
uid: The club's id in the game database (unconfirmed).
uid: The id other records in this save use to refer to this club. It can differ
between separate careers, so use unique_id to match clubs across saves
(unconfirmed).
name: Full club name (unconfirmed).
short_name: Short club name (unconfirmed).
nation_id: Id of the nation whose league the club plays in (unconfirmed).
Expand All @@ -57,6 +59,8 @@ class Club:
value.
last_league_position: League position at the end of the last completed season, or
None when the save holds none. It is not the position in the current table.
unique_id: The club's Unique ID in the game database, for matching across separate
careers. None when it cannot be read (unconfirmed).
"""

uid: int
Expand All @@ -70,6 +74,7 @@ class Club:
parent_club_name: str | None
reputation: int | None
last_league_position: int | None
unique_id: int | None = None


register_field_statuses(Team, unconfirmed=("team_id", "slot", "club_uid", "is_affiliate"))
Expand All @@ -78,6 +83,7 @@ class Club:
verified=("reputation", "last_league_position"),
unconfirmed=(
"uid",
"unique_id",
"name",
"short_name",
"nation_id",
Expand Down
4 changes: 4 additions & 0 deletions src/fmsave/readers/clubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ def read_club_index(game_db: bytes, layouts: ClubLayouts, file_name: str) -> Clu
for position, (reputation, last_league_position) in zip(index_order, statuses, strict=True):
record = records[position]
parent_club_uid = affiliates.parent_by_club_uid.get(record.uid)
# As with people, the next object's header closes this club's object and carries
# its database Unique ID. The final accepted club has no known closing header.
unique_id = records[position + 1].uid if position + 1 < len(records) else None
club = Club(
uid=record.uid,
name=record.name,
Expand All @@ -206,6 +209,7 @@ def read_club_index(game_db: bytes, layouts: ClubLayouts, file_name: str) -> Clu
parent_club_name=None if parent_club_uid is None else name_by_uid.get(parent_club_uid),
reputation=reputation,
last_league_position=last_league_position,
unique_id=unique_id,
)
clubs.append(club)
uid_by_club_index[record.club_index] = record.uid
Expand Down
4 changes: 2 additions & 2 deletions tests/__snapshots__/test_layouts.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@
'competition_id_limit': 1000000,
'competition_id_offset': 13,
'group_id_offset': 17,
'max_search_bytes': 16000000,
'max_search_bytes': 33554432,
'previous_stage_id_offset': 0,
'resynchronisation_bytes': 4096,
'round_offset': 21,
Expand Down Expand Up @@ -2035,7 +2035,7 @@
),
'stage_table_tail_bytes': tuple(
None,
16777216,
33554432,
),
'stage_trailing_sentinel': tuple(
0.95,
Expand Down
1 change: 1 addition & 0 deletions tests/__snapshots__/test_output_schema.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
'parent_club_name',
'reputation',
'last_league_position',
'unique_id',
]),
'ClubFacilities': list([
'club_uid',
Expand Down
21 changes: 21 additions & 0 deletions tests/test_clubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,29 @@ def test_reads_every_club_with_its_fields() -> None:
parent_club_name=None,
reputation=6500,
last_league_position=2,
unique_id=5002,
)


def test_club_unique_id_comes_from_the_next_header_not_its_own_uid() -> None:
original = read_index(example_game_db(status_records=[])).clubs
changed = read_index(
example_game_db(
club_records=[
replace(NORTHBRIDGE, uid=6001).record_bytes(),
SOUTHPORT.record_bytes(),
ATHLETIC.record_bytes(),
],
status_records=[],
)
).clubs

assert original[0].uid == 5001
assert changed[0].uid == 6001
assert original[0].unique_id == changed[0].unique_id == 5002
assert changed[-1].unique_id is None


def test_cross_border_stub_club_has_no_reputation() -> None:
southport = read_index(example_game_db()).club_by_uid[5002]
assert southport.name == "Southport Example"
Expand Down Expand Up @@ -794,6 +814,7 @@ def test_clubs_export_with_nested_teams() -> None:
{"team_id": 70002, "slot": 1, "club_uid": 5001, "is_affiliate": False},
]
assert first_row["reputation"] == 6500
assert first_row["unique_id"] == 5002


def test_registered_club_layouts() -> None:
Expand Down
13 changes: 12 additions & 1 deletion tests/test_stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,9 @@ def test_healthy_stage_stats_pass_every_gate_and_a_small_section_applies_none()
pytest.param({"gaps": 9}, ["stage_walk_gaps"], id="gaps-above"),
pytest.param({"with_competition": 6_400}, ["stage_rows_with_competition"], id="few-joins"),
pytest.param({"bytes_after_table": 4_132_399}, [], id="older-build-career-tail"),
pytest.param({"bytes_after_table": 20_693_727}, [], id="continued-career-tail"),
pytest.param(
{"bytes_after_table": 17_000_000}, ["stage_table_tail_bytes"], id="table-too-early"
{"bytes_after_table": 34_000_000}, ["stage_table_tail_bytes"], id="table-too-early"
),
pytest.param({"rows": 999}, ["stage_rows_minimum"], id="too-few-rows"),
pytest.param({"ascending_steps": 7_000}, ["stage_ids_ascending"], id="ids-not-ascending"),
Expand Down Expand Up @@ -594,6 +595,16 @@ def test_a_table_further_back_than_the_first_window_is_found_by_widening_it() ->
assert stage_index.stats.bytes_after_table == layout.search_bytes * 2


def test_a_table_with_twenty_megabytes_after_it_is_found() -> None:
layout = find_stage_layout(GAME_DB_SCHEMA, "")
game_db = stage_table_bytes(career_stage_rows(), trailing_bytes=20_693_727)

stage_index = read_stage_index(game_db, layout, FILE_NAME)

assert len(stage_index.stages) == STAGE_ROW_COUNT
assert stage_index.stats.bytes_after_table == 20_693_727


def test_a_table_past_the_widest_window_is_not_found() -> None:
layout = small_search_layout()
game_db = stage_table_bytes(career_stage_rows(), trailing_bytes=layout.max_search_bytes)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading