Last active
May 5, 2026 17:25
-
-
Save zett42/52c04b4315d37d00b0a62456166a882d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <#! | |
| .SYNOPSIS | |
| Patches a SCORE.BO file for the DOS PC game Bolo by Dongleware to unlock training mode. | |
| .DESCRIPTION | |
| Updates one SCORE.BO record with a caller-supplied name and score, then recomputes the 16-bit checksum expected by the DOS executable. | |
| To unlock training mode, set the score to >= 50000. | |
| DISCLAIMER: This script is an unofficial community utility and is not provided, endorsed, or supported by Dongleware. | |
| It is provided for educational purposes only. | |
| .PARAMETER ScoreFile | |
| Path to the SCORE.BO file to patch. | |
| Defaults to this sub path relative to the script location, which assumes the script is put into the root of a DOSBox installation of Bolo: | |
| "\DATA\HD\DOS\PREF\BOLO\SCORE.BO" | |
| .PARAMETER Name | |
| ASCII player name to write into the selected score record. The name must fit in 17 characters. | |
| .PARAMETER Score | |
| Score value to encode as packed BCD in the selected record. | |
| .PARAMETER RecordIndex | |
| Zero-based score record index to overwrite. Valid values are 0 through 5. | |
| .EXAMPLE | |
| .\Unlock-BoloTraining.ps1 -ScoreFile .\SCORE.BO -Name FREEPLAY -Score 50000 -RecordIndex 0 | |
| #> | |
| param( | |
| [Parameter()] | |
| [string] $ScoreFile = (Join-Path $PSScriptRoot 'DATA\HD\DOS\PREF\BOLO\SCORE.BO'), | |
| [string] $Name = 'FREEPLAY', | |
| [int] $Score = 50000, | |
| [int] $RecordIndex = 0 | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Runs the script workflow. | |
| .DESCRIPTION | |
| Validates the caller input, loads SCORE.BO, updates one score record, recalculates the | |
| checksum, writes the patched file back to disk, and returns a summary object. | |
| #> | |
| function Start-Main { | |
| # Validate the caller input before touching the score file. | |
| Assert-ValidInputs -ScoreFile $ScoreFile -Name $Name -Score $Score -RecordIndex $RecordIndex | |
| $bytes = Read-ScoreFileBytes -ScoreFile $ScoreFile | |
| Set-ScoreRecord -Data $bytes -Name $Name -Score $Score -RecordIndex $RecordIndex | |
| $checksum = Set-ScoreChecksum -Data $bytes | |
| $backupPath = Backup-ScoreFile -ScoreFile $ScoreFile | |
| [System.IO.File]::WriteAllBytes($ScoreFile, $bytes) | |
| [pscustomobject]@{ | |
| ScoreFile = $ScoreFile | |
| BackupFile = $backupPath | |
| Name = $Name | |
| Score = $Score | |
| RecordIndex = $RecordIndex | |
| Checksum = ('0x{0:X4}' -f $checksum) | |
| } | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Validates caller input for the patch operation. | |
| .DESCRIPTION | |
| Checks the score file path, record index, score range, and encoded name length before the | |
| script reads or mutates SCORE.BO. | |
| .PARAMETER ScoreFile | |
| Path to the SCORE.BO file. | |
| .PARAMETER Name | |
| ASCII player name to write into the score record. | |
| .PARAMETER Score | |
| Score value to encode into the score record. | |
| .PARAMETER RecordIndex | |
| Zero-based score record index to overwrite. | |
| #> | |
| function Assert-ValidInputs { | |
| param( | |
| [string] $ScoreFile, | |
| [string] $Name, | |
| [int] $Score, | |
| [int] $RecordIndex | |
| ) | |
| # SCORE.BO always contains exactly six fixed-size records. | |
| if ($RecordIndex -lt 0 -or $RecordIndex -gt 5) { | |
| throw 'RecordIndex must be between 0 and 5.' | |
| } | |
| if ($Score -lt 0 -or $Score -gt 99999999) { | |
| throw 'Score must be between 0 and 99999999.' | |
| } | |
| if (-not (Test-Path -LiteralPath $ScoreFile)) { | |
| throw "Score file not found: $ScoreFile" | |
| } | |
| $nameBytes = [Text.Encoding]::ASCII.GetBytes($Name) | |
| if ($nameBytes.Length -gt 17) { | |
| throw 'Name must be at most 17 ASCII characters.' | |
| } | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Reads SCORE.BO from disk. | |
| .DESCRIPTION | |
| Loads the file as raw bytes and verifies that it matches the expected six-record SCORE.BO | |
| layout of 134 bytes. | |
| .PARAMETER ScoreFile | |
| Path to the SCORE.BO file. | |
| #> | |
| function Read-ScoreFileBytes { | |
| param([string] $ScoreFile) | |
| # SCORE.BO is always six 22-byte records plus a 2-byte checksum. | |
| $bytes = [System.IO.File]::ReadAllBytes($ScoreFile) | |
| if ($bytes.Length -ne 134) { | |
| throw "Unexpected SCORE.BO size: $($bytes.Length) bytes" | |
| } | |
| # Use comma-operator to avoid unrolling the byte array into individual bytes in the caller's scope. | |
| , $bytes | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Creates a backup copy of SCORE.BO before patching. | |
| .DESCRIPTION | |
| Copies the original score file to a sibling backup path using the first available name in | |
| the sequence .bak, .bak1, .bak2, and so on. | |
| .PARAMETER ScoreFile | |
| Path to the SCORE.BO file that will be backed up. | |
| #> | |
| function Backup-ScoreFile { | |
| param([string] $ScoreFile) | |
| $index = 0 | |
| while ($true) { | |
| $suffix = if ($index -eq 0) { '.bak' } else { ".bak$index" } | |
| $backupPath = "$ScoreFile$suffix" | |
| if (-not (Test-Path -LiteralPath $backupPath)) { | |
| Copy-Item -LiteralPath $ScoreFile -Destination $backupPath | |
| return $backupPath | |
| } | |
| $index++ | |
| } | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Writes one score record into the in-memory SCORE.BO buffer. | |
| .DESCRIPTION | |
| Replaces the selected record's name and packed-BCD score fields while preserving the rest | |
| of the file contents for later checksum recalculation. | |
| .PARAMETER Data | |
| Mutable SCORE.BO byte buffer. | |
| .PARAMETER Name | |
| ASCII player name to write into the selected record. | |
| .PARAMETER Score | |
| Score value to encode as packed BCD. | |
| .PARAMETER RecordIndex | |
| Zero-based record index to overwrite. | |
| #> | |
| function Set-ScoreRecord { | |
| param( | |
| [byte[]] $Data, | |
| [string] $Name, | |
| [int] $Score, | |
| [int] $RecordIndex | |
| ) | |
| $offset = $RecordIndex * 22 | |
| $nameBytes = [Text.Encoding]::ASCII.GetBytes($Name) | |
| $scoreBytes = ConvertTo-BoloBcdBytes -Value $Score | |
| # Reset the whole 18-byte name field to dashes before writing the new name. | |
| for ($i = 0; $i -lt 18; $i++) { | |
| $Data[$offset + $i] = 0x2D | |
| } | |
| # Write the name bytes into the record, leaving the remaining bytes as dashes if the name is shorter than 18 characters. | |
| for ($i = 0; $i -lt $nameBytes.Length; $i++) { | |
| $Data[$offset + $i] = $nameBytes[$i] | |
| } | |
| # The in-game name parser stops at the first NUL byte. | |
| $Data[$offset + $nameBytes.Length] = 0x00 | |
| for ($i = 0; $i -lt 4; $i++) { | |
| $Data[$offset + 18 + $i] = $scoreBytes[$i] | |
| } | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Encodes a score as Bolo packed BCD bytes. | |
| .DESCRIPTION | |
| Converts an integer score into the four-byte packed-BCD representation used by SCORE.BO. | |
| .PARAMETER Value | |
| Score value to encode. | |
| #> | |
| function ConvertTo-BoloBcdBytes { | |
| param([int] $Value) | |
| # Scores are stored as four packed-BCD bytes in little-endian pair order. | |
| $digits = $Value.ToString('D8') | |
| for ($i = 6; $i -ge 0; $i -= 2) { | |
| $lowDigit = [int] $digits[$i] - 48 | |
| $highDigit = [int] $digits[$i + 1] - 48 | |
| $packedByte = $lowDigit -bor (($highDigit -band 0x0F) -shl 4) | |
| [byte] $packedByte | |
| } | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Recomputes and writes the SCORE.BO checksum. | |
| .DESCRIPTION | |
| Calculates the 16-bit checksum used by the game and stores it in the trailing checksum field. | |
| .PARAMETER Data | |
| Mutable SCORE.BO byte buffer. | |
| #> | |
| function Set-ScoreChecksum { | |
| param([byte[]] $Data) | |
| $checksum = Get-BoloChecksum -Data $Data | |
| [BitConverter]::GetBytes($checksum).CopyTo($Data, 132) | |
| $checksum | |
| } | |
| <# --------------------------------------------------------------------------------------------------- | |
| .SYNOPSIS | |
| Calculates the SCORE.BO checksum. | |
| .DESCRIPTION | |
| Implements the checksum routine reverse-engineered from the DOS executable: start from the | |
| seed value 0x007B, add each record's visible name bytes until the first NUL, then add the | |
| two 16-bit score words for all six records, folding the result to 16 bits. | |
| .PARAMETER Data | |
| SCORE.BO byte buffer to checksum. | |
| #> | |
| function Get-BoloChecksum { | |
| param([byte[]] $Data) | |
| # The DOS executable seeds the checksum with 0x007B and then folds each | |
| # record's visible name bytes plus its two score words into a 16-bit sum. | |
| $sum = 0x7B | |
| for ($record = 0; $record -lt 6; $record++) { | |
| $offset = $record * 22 | |
| for ($i = 0; $i -lt 18; $i++) { | |
| $value = $Data[$offset + $i] | |
| if ($value -eq 0) { | |
| break | |
| } | |
| $sum = ($sum + $value) -band 0xFFFF | |
| } | |
| $scoreLow = [BitConverter]::ToUInt16($Data, $offset + 18) | |
| $scoreHigh = [BitConverter]::ToUInt16($Data, $offset + 20) | |
| $sum = ($sum + $scoreLow + $scoreHigh) -band 0xFFFF | |
| } | |
| [uint16]$sum | |
| } | |
| # Invoke the main entry point and return its result to the caller. | |
| Start-Main |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment