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
46 changes: 46 additions & 0 deletions .github/workflows/coop-deployment-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Coop deployment scripts

on:
push:
paths:
- 'apps/faf-legacy-deployment/scripts/**'
- '.github/workflows/coop-deployment-scripts.yml'
pull_request:
paths:
- 'apps/faf-legacy-deployment/scripts/**'
- '.github/workflows/coop-deployment-scripts.yml'

# the job builds and runs repository controlled Kotlin, so it gets nothing but read access
permissions:
contents: read

jobs:
verify:

runs-on: ubuntu-latest
container:
# same image the deployment CronJobs use
image: gradle:9.4-jdk21

steps:
- uses: actions/checkout@v6
with:
# nothing here pushes, so the job token has no business staying in .git/config
persist-credentials: false

- name: Check out the coop missions
uses: actions/checkout@v6
with:
repository: FAForever/faf-coop-maps
path: faf-coop-maps
Comment thread
coderabbitai[bot] marked this conversation as resolved.
persist-credentials: false

- name: Compile
working-directory: apps/faf-legacy-deployment/scripts
run: gradle --no-daemon compileKotlin

- name: Round trip the path fixer over every mission map
working-directory: apps/faf-legacy-deployment/scripts
env:
MAPS_REPO: ${{ github.workspace }}/faf-coop-maps
run: gradle --no-daemon verifyScmapFixer
135 changes: 119 additions & 16 deletions apps/faf-legacy-deployment/scripts/CoopMapDeployer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@ import com.faforever.FafDatabase
import com.faforever.GitRepo
import com.faforever.Log
import com.faforever.extractChecksumsFromZip
import com.faforever.fixScmapPaths
import com.faforever.generateChecksums
import com.faforever.needsPathFix
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import kotlin.io.path.copyTo
import kotlin.io.path.createDirectories
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.readBytes
import kotlin.io.path.readText
import kotlin.io.path.walk
import kotlin.system.exitProcess
import kotlin.toString

private val log = LoggerFactory.getLogger("coop-maps-updater")

Expand Down Expand Up @@ -151,9 +156,17 @@ private fun processCoopMap(
val newVersion = currentVersion + 1
log.info("$map updated → v$newVersion")

verifyRelease(map, newVersion, files, tmp)

if (!simulate) {
val finalZip = Path.of(mapsDir, map.zipName(newVersion))
createZip(map, newVersion, files, tmp, finalZip)
val partialZip = Path.of(mapsDir, "${map.zipName(newVersion)}.part")
try {
createZip(map, newVersion, files, tmp, partialZip)
Files.move(partialZip, finalZip, StandardCopyOption.ATOMIC_MOVE)
} finally {
Files.deleteIfExists(partialZip)
}
db.update(map, newVersion)
}
} finally {
Expand All @@ -178,24 +191,102 @@ private fun generateChecksumsForMap(

/**
* Get file content with path rewriting for text files.
*
* Text files are read and written as ISO-8859-1. Latin-1 maps every byte to exactly one
* char and back, and the replacements below only ever touch ASCII, so a file comes out
* byte identical no matter what it is really encoded in. Reading as UTF-8 would turn
* anything that is not valid UTF-8 into U+FFFD and write that into the release instead.
* Every text file in the missions is valid UTF-8 today, so this changes no checksum - it
* only keeps the first file with a Latin-1 accent in it from being corrupted silently.
*/
private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray {
return if (file.isTextFile()) {
var text = file.readText()
.replace(
"/maps/${map.folderName}/",
"/maps/${map.folderName(version)}/",
ignoreCase = true,
)
if (file.toString().endsWith("_scenario.lua")) {
text = text.replace(Regex("""(map_version\s*=\s*)\d+"""), "$1$version")
private fun getFileContent(file: Path, map: CoopMap, version: Int): ByteArray =
when {
file.isScmapFile() -> {
val bytes = file.readBytes()
// Only missions whose map references assets in their own folder need the version
// inserted. Everything else - including the placeholder .scmap files of the missions
// that use a base game map - is passed through and never parsed.
if (bytes.needsPathFix(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).bytes
} else {
bytes
}
}
file.isTextFile() -> {
var text = file.readText(Charsets.ISO_8859_1)
.replace(
"/maps/${map.folderName}/",
"/maps/${map.folderName(version)}/",
ignoreCase = true,
)
if (file.toString().endsWith("_scenario.lua", ignoreCase = true)) {
text = text.replace(Regex("""(map_version\s*=\s*)\d+"""), "$1$version")
}
text.toByteArray(Charsets.ISO_8859_1)
}
text.toByteArray()
} else {
file.readBytes()
else -> file.readBytes()
}

/**
* Checks that every path the release points at actually exists in the release.
*
* The paths rewritten inside a .scmap are checked hard: if one of them does not resolve to
* a file that ends up in the zip, the mission is not deployed at all. That is the case the
* whole path rewriting exists for, and getting it wrong ships a map with missing textures
* that nothing else would notice.
*
* References in text files are only reported. Some of them have been broken for years -
* typos in comment headers - and failing on those would block releases for cosmetic reasons.
*
* @throws IllegalStateException if a rewritten map path does not resolve
*/
private fun verifyRelease(map: CoopMap, version: Int, files: List<Path>, base: Path) {
// Everything below is compared case insensitively, and it has to be: 28 of the paths
// embedded in the map files point at lower case names while the files themselves are
// mixed case. That is in the missions as committed, it predates this deployment, and
// those maps load - a case sensitive comparison would refuse releases that demonstrably
// work. What the rewriting must not do is add mismatches on top of that; see addVersion.
val prefix = "/maps/${map.folderName(version)}/".lowercase()
val shipped = files
.map { prefix + base.relativize(it).toString().replace("\\", "/").lowercase() }
.toSet()

// over capture is possible when a path is read out of binary data, so a reference counts
// as resolved when it starts with a shipped file
fun resolves(reference: String) =
shipped.any { reference.lowercase() == it || reference.lowercase().startsWith(it) }
Comment on lines +249 to +257

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of .lowercase() most probably breaks it on Linux. This is quite important as path checks on the server would fail. Other places are affected too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the comparison in verifyRelease. That one only compares two strings built in this process, it never touches the filesystem, so Linux makes no difference as far as i know. The problem sat one level down, in addVersion, which lower cased the whole path.

Of the 40 embedded paths, 28 already point at lower case names while the files on disk are mixed case. That is in the .scmap as committed and those missions load today, so the comparison has to stay case insensitive or it rejects 28 working paths. The other 4 are the decals of Golden_Crystals and Overlord_Surth_Velsok, and they reference exactly the casing their files carry. Lower casing the tail broke those. That is the one your comment found.

ea7aa84 lower cases only the /maps/.vNNNN head. Exact matches against the files in the release go from 6 to 10. 13ddc10 writes the reason for the case insensitive comparison into verifyRelease.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to what I wrote above, plus measurements that settle the Linux question.

First the correction. I called those 28 paths "working paths". They are not. All 40 rewritten paths sit in the four missions whose folder is unversioned, so every one of them is broken today. That is the bug this PR fixes, and it means they prove nothing about case handling. The conclusion still holds, but for a different reason: the case mismatch is in the missions as committed, the deployment cannot repair it, and a case sensitive comparison would refuse Blockade and Tha_Atha_Aez, two of the four missions this exists for.

Second, the engine does not care about case, on any platform. From a game log:

DISK: AddSearchPath: 'c:\users\...\maps\12 fields of isis v13', mounted as '/maps/12 fields of isis v13/'

That directory is named 12 Fields of Isis V13 on disk. The engine lower cases the disk path and the mount point itself. It matches gpg::STR_CanonizeFilename, which lower cases every path before a lookup, and the zip entry index, which is keyed by gpg::STR_CompareNoCase. The casing inside a .scmap therefore does not decide whether a file is found. ea7aa84 is hardening rather than a bug fix, and your Linux concern lands on the file names rather than the paths, which is not something the deployment can change.

Third, there is a reference to diff against after all, just not on the content server: I still had the hand repaired copies installed locally. Running the fixer over the repo files with the matching version and comparing byte for byte:

mission differing bytes upper to lower anything else
Blockade v0004 1 1 0
Tha_Atha_Aez v0015 5 5 0
Overlord_Surth_Velsok v0001 112 112 0

Identical size in all three, identical paths, and every differing byte is an ASCII case flip. Some of them are the decal casing from ea7aa84, the rest are base game paths such as /env/Evergreen/layers/macrotexture000_albedo.dds that fix_paths lower cases and this does not.

And the deployed artifacts are the unfixed ones. Deleting the two missions and letting the client download them again produced files byte identical to the repo, with the missing terrain textures visible in game.

@TimMasalme TimMasalme Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the loop on the case question. The divergence from fix_paths is now measured across all four affected missions, against the public-upload files from the Coop-Discord:

mission version differing bytes upper to lower anything else
Blockade v0004 1 1 0
Tha_Atha_Aez v0015 5 5 0
Golden_Crystals v0003 81 81 0
Overlord_Surth_Velsok v0001 112 112 0

Roughly 60 MB of binary, 199 differing bytes, every one of them an ASCII case flip and nothing structural, across all three format versions. So the entire difference between this and the hand repairs is casing, and the engine does not read it: STR_CanonizeFilename lower cases the query before a lookup, the zip entry index compares with STR_CompareNoCase, and a game log shows a directory named 12 Fields of Isis V13 mounting as /maps/12 fields of isis v13/.

Verified in the game as well. The three affected missions that were installed locally were deleted, downloaded again through the client, confirmed byte identical to the repo files, and the textures were missing. With the fixer output at the same version they all render correctly,

Details are in the description under Verification.


val broken = mutableListOf<String>()

files.forEach { file ->
val relative = base.relativize(file).toString().replace("\\", "/")

if (file.isScmapFile()) {
val bytes = file.readBytes()
if (bytes.needsPathFix(map.folderName)) {
fixScmapPaths(bytes, map.folderName, version).rewritten
.filterNot(::resolves)
.forEach { broken += "$relative points at $it, which is not in the release" }
}
} else if (file.isTextFile()) {
val text = String(getFileContent(file, map, version), Charsets.ISO_8859_1)
Comment thread
Brutus5000 marked this conversation as resolved.
MAP_REFERENCE.findAll(text)
.map { it.value }
.filter { it.lowercase().startsWith("/maps/${map.folderName.lowercase()}") }
.filterNot(::resolves)
.distinct()
.forEach { log.warn("$map: $relative points at $it, which is not in the release") }
}
}

check(broken.isEmpty()) {
broken.forEach { log.error("$map: $it") }
"$map: ${broken.size} rewritten map path(s) do not resolve, not deploying this mission"
}
}

private val MAP_REFERENCE = Regex("""/maps/[^"'\s,)]+""", RegexOption.IGNORE_CASE)

private fun createZip(
map: CoopMap,
version: Int,
Expand Down Expand Up @@ -236,7 +327,9 @@ private fun createZip(
}
}

private fun Path.isTextFile() = listOf(".md", ".lua", ".json", ".txt").any { toString().endsWith(it) }
private fun Path.isTextFile() = listOf(".md", ".lua", ".json", ".txt").any { toString().endsWith(it, ignoreCase = true) }

private fun Path.isScmapFile() = toString().endsWith(".scmap", ignoreCase = true)

fun main(args: Array<String>) {
Log.init()
Expand All @@ -258,13 +351,23 @@ fun main(args: Array<String>) {
gitRef = GIT_REF,
).checkout()

val failed = mutableListOf<CoopMap>()

CoopMapDatabase(dryRun = DRYRUN).use { db ->
coopMaps.forEach {
try {
processCoopMap(db, it, DRYRUN, WORKDIR, MAP_DIR)
} catch (e: Exception) {
log.warn("Failed processing $it", e)
failed += it
log.error("Failed processing $it", e)
}
}
}

// one mission failing must not stop the others, but it may not pass for a successful run
// either - a refused release is only visible in the logs otherwise
if (failed.isNotEmpty()) {
log.error("{} mission(s) were not deployed: {}", failed.size, failed.joinToString { it.folderName })
exitProcess(1)
}
}
Loading