This project follows Keep a Changelog and Semantic Versioning.
- The web app's error screen has a Copy report button. It copies the fmsave and Pyodide versions, the game and build when known, and the error, without the file name or anything from the save.
- The README, CONTRIBUTING.md and issue forms say what helps most in a report: a save sent privately, then your own format findings, then
fmsave validateoutput and cropped screenshots. Posting a save, or a link to one, stays off limits. - Compatibility:
Stadium.all_seater_capacityis renamedtotal_capacity, since it holds the ground's total capacity, seated and standing together, not the seated figure. The export column is renamed the same way. Code or spreadsheets that use the old name need updating. (#38) - Docs:
Stadium.capacityis the capacity used in normal matches, and a zeroseated_capacitymeans the game shows a single figure with no seated part. Both are from one FM27 ground each and stay unconfirmed. (#38)
Thanks to @jeohist for help testing. Refs #38.
- Validation reports more
tacticscounts about tactic records stored under a signature fmsave does not list:unlisted_record_units, the setting units in the records read, andunexpected_unit_separators, those whose separator byte is not the usual value. Those units are also counted by value asunit_separator_<value>, by slot block asunit_separator_block<block>, and by place in the block asunit_separator_unit<unit>, each appearing only when nonzero. (#31) tactics(),set_pieces()andselections()read FM27 saves whose set-piece routines have names, such as a career carried over from FM26, instead of raisingUnsupportedGameError. The names come back inSetPieceRoutine.nameas on FM26, and every routine group must still be read whole for theset_pieceschecks to pass. Saves with only unnamed routines read as before. (#56)
tactics()reads tactic records stored under a signature fmsave does not list when some of their setting units store a value other than the usual one in the byte between their two bit fields. That byte is counted in validation rather than shipped, and every other check on these records still applies. Records under a listed signature are read as before. (#31)- A save whose
game_infofilename list claims more entries than fmsave allows now raisesReaderCheckErrorsaying the save layout differs from what fmsave expects and asking for a report, instead ofCorruptSaveErrorsaying the file is damaged and to wait or copy it. Such a count comes from a layout fmsave does not know, not from a damaged file. The command line exits with status 3 for it rather than 1. (#56) - FM27 saves whose
game_infofilename list holds entries now open. fmsave read the list's count two bytes too early on FM27, so such a save was refused as a layout mismatch. Saves with an empty list read as before. (#56) training()andmentoring()on FM27 read saves whose training section holds per-person header entries or mentoring groups, such as a career carried over from FM26, instead of raisingUnsupportedGameError. fmsave read an FM27 team block's mentoring group count four bytes too late and its groups in the FM26 shape. A block whose groups are followed by bytes that are not zero still raisesUnsupportedGameError. Saves with no header entries and no groups read as before. (#56)staff()andstaff_lists()on FM27 count a staff code list stored as all zeros as one that is not set, rather than as codes outside the observed set, sostaff_codes_in_setno longer fails on a save holding many of them. A list mixing zeros with other codes still counts against the check, and FM26 is unchanged. (#56)
Thanks to @mameabdou24 for the report, @sebheusig for researching the FM27 game_info layout, and @OdumWorld for help testing. Refs #31, #56.
- Validation reports two
clubscounts:clubs_without_main_nation, the clubs read with no league nation, andteamless_rows_without_main_nation, the club-table rows with no league nation and no team list, which stay left out. (#53) - Validation reports two more kinds of
tacticscounts:unknown_signature_records_read, and anunknown_signature_byte<position>_<value>count for each byte where a tactic record's signature differs from the ones fmsave lists. (#31)
- Compatibility:
Club.nation_idis nowint | None. It is None for clubs the game stores with no league nation, such as some school and university teams;fa_nation_idis still set for them. Code that assumes an int, such as sorting or indexing by it, needs to handle None. In exports the column is empty for these clubs, and they fall in no--nationscope. (#53)
clubs()now reads clubs the game stores with no league nation when they have a team list, such as school and university teams. Players home grown at those clubs get theirhome_grown_club_uidsandhome_grown_club_namesinstead of None. Rows with no league nation and no team list are still left out. (#53)tactics()reads tactic records stored under a signature variant fmsave does not list, sotactic_records_count_matchingno longer fails because of one. A team's records are read this way only when every record its count claims is found whole; otherwise the check still fails as before. (#31)
Thanks to @guyluzon64 for the report and @OdumWorld for help testing. Refs #31, #53.
- Validation reports five more
tacticscounts that show why a team has no tactic rows:empty_tactic_blocks,flagged_tactic_counts,missing_tactics,unknown_signature_records, andsignatures_after_routines. (#31) fmsave export league-tablesandinjury-typesprint a note on standard error when they write no rows. (#31)- Validation reports six
playerscounts that say where unresolved home-grown club references point:home_grown_refs_to_extended_rows,home_grown_refs_to_unanchored_rows,home_grown_refs_to_rejected_rows,home_grown_refs_out_of_range,home_grown_refs_empty, andhome_grown_refs_other. (#53)
- FM27 build 27.0.0+2486695 now reports
known_buildtrue; its fields stay unconfirmed andfmsave infostill shows partial coverage. (#31) - Unregistered FM27 builds open, borrowing 27.0.0+2486695 layouts where section schemas match. A reader whose section schema differs raises
UnsupportedGameError,known_buildstays false,fmsave infoshows "unverified build", andUnknownBuildWarningsays so.
- Large saves open however big they grow: fmsave no longer caps a save at fixed sizes (FM27 at 256 MiB for one part and 512 MiB in all, FM26 at 1 GiB and 4 GiB). A part past 64 MiB that claims or decompresses to more than 64 times its compressed size, or a save whose parts add up to more than 32 times its file size, raises
CorruptSaveErrorbefore that memory is spent. The message gives the ratio and asks for a report if the game opens the save. (#52) experimental_span()reads large FM27 spans: its 128 MiB, 256 MiB, 100,000-fixture and 4,096-rule limits are gone, and its count limits grow with the span. (#52)- The app asks for a report when a fresh copy of a save still reads as damaged. (#52)
- Docs: injury types and league tables can be empty before a season's first matches. (#31)
players()no longer failshome_grown_club_refs_resolvedon saves where many players are home grown at clubs the club reader leaves out. References to unanchored or rejected club-table rows are left out of that check, since they point at real club records; they still read as None and are counted in the new validation counts. References to extended rows, past the table's end, or empty still count against the check. (#53)
Thanks to @guyluzon64 and @rui7avares-create for the reports, and @OdumWorld and @jeohist for help testing. Refs #31, #52, #53.
- FM27 reads
fixtures(),league_tables(), andcompetition_rules()(fields unconfirmed). (#42) - FM27 reads
injuries(),injury_types(), andplayer_match_stats()with populated histories (fields unconfirmed). (#45) - FM27 reads
suspensions(); ban coverage is not yet compared with the game. (#8) - FM27
tactics()andset_pieces()read careers with a saved tactic. (#43) selections()and theselectionsexport table: each managed team's stored picks (fields unconfirmed). (#43)Staff.jobandStaffJobon FM27, with 17 job codes named. (#44)- FM27
staff()rows for club roles held with no contract, such as the chairperson. (#44) TrainingWeek.days: each week's 7 days and their sessions. (#46)ManagedClub.balance,scouting_budget, andscouting_budget_remainingon FM27 (unconfirmed; None on FM26). (#47)- Validation reports a
suspension_entries_minimumcheck.
LeagueTableRow.rounds_per_venueisint | None: None, with nomatches, for a row stored without match slots.- On FM27, a table row's won, drawn, and lost can add up to fewer than played in some cup group stages.
- 11
TacticPositionbits are labelled, on FM26 too, so export position labels change. (#43) Staffgainsjobafterteam_slot, which moves later CSV columns and breaks positional construction. (#44)- On FM27, contract offers no longer appear as
staff()rows or replace real terms. (#44) - On FM27, a failed suspension check stops only
suspensions(); a failed selection check stops onlyselections(). (#8, #43) - FM27
stadiums()reads withhome_club_uidsunknown when the calendar fails its checks. (#42) - The
neutral_venue_votesvalidation count now counts teams, not clubs. fmsave infoshows the FM27 build as partial coverage, not unknown.
- FM26 table rows without match slots no longer read match rows from unrelated bytes or split their table.
fixtures()no longer marks first-team home matches neutral when reserve or youth sides use other grounds.- Unregistered free agents no longer get another person's contract end.
managed_clubs()findsmanager_staff_uidwhen a second person header carries his id.- The web app keeps the open save working after another file fails to open.
- Docs: FM26
player_match_stats()rows include friendlies, internationals, and other teams' matches. - Docs:
has_statsfalse, the contract in effect, money units, mentoring labels, and competition id spaces.
Thanks to @jeohist for the reports. Refs #8, #42, #43, #44, #45, #46, #47.
Stadium.seated_capacityholds the seated capacity.all_seater_capacityis documented as the total.- Corporate facility ratings 3 and 4 read Fairly Basic and 16 reads Excellent. A new
NONErating covers 0. - FM27 sponsorship kinds named on the game's Sponsorships screen have labels.
stadiums()includes the first ground in the stadium table, so its club's home matches resolve.Stadium.built_dateandrebuilt_datekeep years before 1900.goals_outside_boxis now empty on FM26, as on FM27, because the stored count can exceed goals.- FM27
experimental_span()andvalidateno longer report a healthy save as corrupt as it grows. The span is held in memory once. - The human manager's
staff()row has their name on FM27. On a new FM26 career it no longer shows the next person's details. - League tables and competition rules no longer take the friendlies competition's id when friendlies cover the same clubs.
- A save whose version string the game cannot write raises
UnsupportedGameErrorinstead of a raw error or the wrong edition's layouts. - A save opened by a relative path keeps working after the working folder changes.
- CONTRIBUTING.md explains that changes come in through issues and how contributors are credited. The README lists contributors, and past changelog entries now thank the people whose reports, requests, testing and findings fed them.
Thanks to @jeohist for the reports. Refs #9, #11, #37, #38, #39, #40, #41.
- FM27 build
27.0.0+2486695opens through the normal library, command line, and web app reader. Other FM27 builds and unregistered layouts are rejected. - FM27 readers use strict checks and conservative memory budgets. Unsupported tables reject, and unconfirmed or unsupported fields keep their explicit status. This does not establish that every FM27 value is decoded correctly.
- The app identifies FM27's unconfirmed field meanings and the reader version needed for FM27. Documentation now covers both editions and their coverage limits.
- Fuzzing exercises a fictional FM27 seed through the real raw-span parser while retaining decompression caps.
-
FM27 sponsorship lists retain boundary-date rows instead of losing the whole club's list. Rejected FM27 rows in a structurally located list now fail an explicit check, even when overall coverage passes. FM26 date boundaries and failure behavior remain unchanged; rejected lists gain a diagnostic count.
-
League-table points below zero, after a points deduction, read as negative instead of about 65,000.
-
league_tables()no longer returns one-club tables that hold an earlier state of a row kept in another table, which reported a stale record in first place. Validation counts them assuperseded_blocks. -
League-table match slots that do not hold one match whose score agrees with its result, such as slots counting two meetings or scoring hundreds of goals, keep their slot and side with the other fields empty. Validation counts them as
unreadable_match_slots. -
read_competition_names()and--competition-namesaccept a file Excel saved as "CSV UTF-8". Its byte order mark no longer hides the first row. A file that is not UTF-8 raises an error that names it. -
Opening a save refuses metadata sections far larger than any save writes, and
player_season_stats()andplayer_season_history()keep to the total decompression cap, so a small damaged or crafted file cannot take minutes and gigabytes to read. -
The web app's CSV downloads put an apostrophe before text a spreadsheet would run as a formula, as its list download already did. A CSV button no longer stays on "Writing" after another save is opened.
- Validation reports
edited_attribute_playersfor players stored past the 1 to 100 raw attribute scale, and the club-table rows that give no club by reason:extended_table_rows,unanchored_table_rowsandrejected_table_rows.
Thanks to @OdumWorld for the report and help testing, and @jeohist for the FM27 comparison. Refs #31.
- A web app reads your save in the browser, with squad and player views, filters, shortlists, and CSV downloads. No Python installation is needed, and your save stays on your computer.
- The web app uses the latest published fmsave release.
- Shared links to the web app and docs include page previews. The docs also provide a sitemap and
llms.txt. nations()pairs save-local nation IDs with game database IDs for looking up names in an external list. ThenationsCLI export provides the same table.Club.genderidentifies men's and women's clubs. Unknown codes retain their original value in exports.
- Valid clubs at the start of the club list are included, with their database IDs and team links. Club uid 0 is valid; use
is not Nonewhen checking whether a club reference resolved. - Club team lists use the club's recorded teams, fixing missing or incorrect squad links.
- Clubs with long names are included.
Club.unique_idmatches the game's club database ID. Re-export club IDs saved with earlier versions.clubs()includes clubs whose nation ID is 0. Their players, contracts, staff, and fixtures join to them.- Fixtures, match records, and league tables include rows involving team 0, a national team.
Thanks to @kdr6989 for the report.
player_season_history()returns archived player statistics with the counts and computed rates of current-season rows. Each row carries an unconfirmed candidate season year and competition reference. Stored order may be nonchronological, and imported history may omit detailed counts.PlayerSeasonHistoryand theplayer-season-historyCLI export table support the new reader. Club and nation scopes select players by current membership and retain their rows for previous teams.- Validation reports historical ownership counts and unresolved teams. Malformed or ambiguous history ownership raises
ReaderCheckError.
- Missing player details stay empty when only another person's data is available. Players no longer borrow that person's name or other personal details, and archived statistics use the same ownership boundary.
- Failed reader caches release decompressed save sections. Repeated calls raise the saved failure without decoding again.
Thanks to @thaffy for the archive lead. Refs #13.
clubs()now includes clubs whose valid nation references differ, including clubs playing across borders. Their teams are available to readers that join through the club index.- Club unique IDs and affiliate-parent links can now resolve through previously omitted clubs.
tactics()andset_pieces()no longer warn or fail strict validation solely because a managed club team has no stored tactics block. Checks for malformed, duplicate, or unread blocks remain enforced.- The
Sponsorship.annual_valueandsponsorships()docs no longer say an ended contract's annual value is zero. Ended contracts often keep a non-zero annual value, so filter onendto find running contracts.
Refs #11.
finances(),sponsorships()andfacilities()no longer give a club the data of a club record stored after it that the club scan does not accept, such as a club playing in another nation's league. Such data was reported under the wrong club, or failedfinance_clubs_with_two_chainswhen both clubs stored a series. Other readers are unaffected and no slower.
Thanks to @Namhahaha1110 for the report. Refs #20.
- 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.
Thanks to @Namhahaha1110 for the report and help testing, and @Buglerru and @hostlund for help testing. Refs #5, #20.
- Unrecognized FM26 builds can open saves that use an already observed metadata layout, including older metadata variants, without requiring a build-specific registration.
- Metadata detection rejects ambiguous or malformed candidates. Other readers retain their existing compatibility checks.
Thanks to @jtstothard for the report. Refs #18.
- Name pools accept longer UTF-8 names, including names exceeding 64 bytes, while retaining entry IDs, section bounds and a 1,024-byte safety ceiling.
- Injury labels accept Unicode space separators, including fullwidth and nonbreaking spaces used in localized text. Invalid UTF-8 and control characters remain rejected.
Thanks to @1e16miin for the report. Refs #14.
- Continued careers with variable metadata now open without false build or date errors, including saves using Russian localization.
- Localized names and larger player and staff populations read without rejecting valid tables.
- Player, staff and injury readers handle additional record variants; tactics validation accepts the broader valid range.
Thanks to @Buglerru for the report and help testing. Refs #5.
player_season_stats()addspossession_lostandnon_penalty_expected_goals, withpossession_lost_per_90andnon_penalty_expected_goals_per_90. All four are unconfirmed.- Saves from build 26.3.1 are read as a known build with the 26.3.2 layouts.
- Saves from build 26.1.0 read the in-game date, so players, contracts, staff, injuries, finances and most other tables load. Fixtures, league tables, tactics and set pieces still fail their checks there, and the build is still reported as unknown.
job_vacancies()reads job-centre records that store a list of ids; such saves no longer fail the section size check.set_pieces()reads user routines whose tail flag differs from the usual value instead of merging them into the next routine.finances()validation accepts a club's first month when its oldest row holds the opening balance, as series begun with the career do. Early-career saves no longer failfinance_balance_continuity. New anomalyopening_balance_steps.league_tables()validation appliesdouble_round_robin_divisionsonly once the fixture calendar shows enough divisions under way, so preseason saves no longer fail it. New anomalystarted_calendar_divisions.facilities()counts a club that stores no corporate-facilities rating (a zero in an all-zero block) as unset instead of out of range. Saves tracking many nations no longer fail its checks. New checkfacility_unset_ratingsand anomalyunset_ratings.- The "game_info does not match build" error says which build check failed.
Thanks to @thaffy for researching possession statistics, @BWH95 and @johnmmcparland for the feature requests, and @hostlund for help testing. Refs #4, #5.
tactics()reads presets and alternate tactics.tactics()andset_pieces()support more continued careers. Validation checks complete team lists, including empty lists, withtactic_records_count_matching; unrelated tactics cannot fill gaps.stadiums()excludes a spurious ground. Validation dropsstadiums.template_rows.staff_lists()keeps valid members beside empty references.- Player and staff readers recover missed people, contracts and season-stat joins. Club data no longer appears as staff contracts.
- Retained contracts without substantive terms no longer count as current paid contracts when registration is absent and no independent current end date exists. Raw chains remain available.
competition_rules()recovers missed rounds, including in continued careers. Legacymatch_countstays raw and is no longer a verified fixture count.fixtures()recovers missing scores, rejects ambiguous or conflicting joins, keeps distinct fixtures with proven ownership, separates final scores from penalties, and leaves unplayed matches unscored.fixtures()recovers omitted calendar rows, including unplayed and trailing fixtures, corrects played statuses, and excludes proven cached copies. Calendar checks still apply; neutral-venue flags use recovered fixtures.player_match_stats()recovers stored histories with missing team references, excludes unrelated data, and detects incomplete or unresolved histories. Empty output passes validation only when every player explicitly has no stored history. Malformed histories no longer cause repeated scans.suspensions()recovers entries with attached match records when the full list can be read.sponsorships()respects each club's primary list, including empty lists, and retains historical contracts. Other sponsor-shaped data cannot replace an empty or invalid list.
set_pieces()recovers missed routines, including multiple user routines per slot, and checks complete groups withset_piece_blocks_complete.players()reads complete records with 0 current ability; their contracts no longer appear as staff contracts.- Fixture validation distinguishes stub teams from missing club joins with
fixture_club_teams_resolved, without lowering the join threshold.
Thanks to @hostlund for help testing. Refs #5.
Club.unique_idhelps match clubs across careers.Club.uidstill joins within a save.
stages()and dependent readers find older stage tables in continued careers.
Thanks to @hostlund for the report and @matthiasvetter1104 for the feature request. Refs #5, #6.
player_season_stats()adds 44 computed rates, includingexpected_goals_per_90,pass_completion_percentandsave_percent. Every export includes them.
player_season_stats()and theplayer-season-statsexport: 56 current-season stats per competition type and team, including minutes, rating, xG, xA, passes, tackles, cards and saves. Types cover league, cup, continental, international, non-competitive, overall and 2 calendar-year totals.
Thanks to @johnmmcparland for the feature request and @thaffy for researching season statistics. Refs #4.
- Careers started before a game update open without a false "game_info does not match build" error.
stages()and dependent readers find stage tables with more data stored after them.
finances()labels months correctly on saves taken on a month's last day.
finances(),sponsorships()andfacilities()read clubs with only 1 or 2 months of finance history, including histories begun mid-career. Short histories must balance and show money movement.- Smaller databases, including FM26 Console saves, now supply most competition database IDs and no longer falsely fail competition validation.
fixtures(),league_tables()andplayer_match_stats()handle dates around 2048. Fixtures were affected from 2040; player match statistics would have failed from 2047.
Player.unique_idandStaff.unique_ididentify people across careers started from the same database, directly afteruidin exports. Deleted people's IDs may be reused.
Player.uidandStaff.uidare documented as save-local join IDs, rather than database IDs. Values and joins are unchanged.
- Windows saves, including FM26 Console saves, no longer falsely fail with "game_info does not match build".
- Smaller databases no longer fail name-pool checks that blocked players, contracts, staff and other name-resolving readers. Name IDs and lengths remain checked.
Thanks to @GetChatGPTToDoIt and @johnmmcparland for the reports. Refs #1, #2.
- Stage-table misreads no longer cause false duplicate-stage errors, including on saves with many playable nations. This restores stages, competitions, fixtures, league tables and player match statistics.
Thanks to @GetChatGPTToDoIt for the report. Refs #1.
- Pre-Continue saves use the summary date when the normal clock is absent.
- Fresh saves no longer falsely fail checks for player attributes, complete join dates or matches without statistics.
- Local single-player whole-save analysis is supported.
--allremains explicit; save-derived data stays out of the repository, and support accepts no save uploads.
The initial public release; earlier versions were never published.
fmsave.open(path)opens an FM26 save as a read-only context manager.Save.infoexposes game, build, database version and in-game date.- 26 readers returning
Tablerecords:players(),contracts(),suspensions(),staff(),staff_lists();clubs(),managed_clubs(),finances(),sponsorships(),facilities(),stadiums(),affiliates(),job_vacancies();stages(),competitions(),fixtures(),league_tables(),competition_rules(),transfer_windows(),player_match_stats();injury_types(),injuries(); and managed-clubtraining(),mentoring(),tactics(),set_pieces(). - Immutable
Tablequeries:where(),filter(),sorted_by(),find(),by_uid(),by_id()andcoverage. Exports:to_dicts(),to_columns(),to_pandas(),to_polars(),write_csv(),write_json()andwrite_jsonl(). Slices remain tables; records survive closing the save. fmsave.field_statusdistinguishes verified and unconfirmed fields.fmsave.OUTPUT_SCHEMA_VERSIONversions flat output.- User-supplied competition names through
competition_names=andfmsave.read_competition_names(), keyed by database ID. - Reader checks warn by default;
strict=Trueraises.fmsave.validate_savereports checks, counts and coverage without names, UIDs or save text. fmsave info,exportandvalidate, plus optionalpandasandpolarsextras.
- Requires Python 3.12+. Never modifies saves, connects to the network or reads the game install.
- The 0.x API may change. Only 1 game build has been read; compatibility across format changes is untested.
- Unreadable values are
None; money keeps the save's unit; unidentified numbers remain raw. - Most competition, league, nation, city and ground names come from the game's installed database and are not shipped. See the README for coverage limits.