maxcrofts.com

Necromancing C++ with AI

Claude is a pathway to many abilities some would find unnatural.

I sank untold hours into Raven Software's Jedi Knight games as a kid. The joy of finally getting Kyle Katarn's lightsaber after slogging through Jedi Outcast's boring FPS levels lives rent-free in my mind. While I never ended up beating the sequel Jedi Academy, recently a long-standing curiosity about part of its engine started nagging at me again. To explore it I would need to get the GPL release compiling. The done thing is to go and find a fork that someone else has already cleaned up; however, the OpenJK project has moved too far along for what I was interested in. So I turned to Claude with the following, very serious prompt:

❯ your mission, should you choose to accept it, is to get this source compiling with visual studio 2026. commit as you go

And after exactly 43 minutes and over 100k tokens spent, I had a compiling (and more importantly working) build of jasp.exe and jagamex86.dll:

Success!

Postmortem

Mission accomplished. The 2003 Jedi Academy single-player source now compiles cleanly under Visual Studio
  2026's v145 toolset, in both Debug and Release.

  What landed:
  - Project migration — replaced the old VS .NET 2003 .vcproj/.sln (Format 7.00) with modern .vcxproj files
  generated by code/convert_vcproj.ps1, plus a Format 12.00 .sln. Pinned to v145 toolset, Win32, with
  /Zc:forScope- for legacy loop-counter scoping.
  - Conformance fixes — modern MSVC rejects default arguments on function-pointer fields, so the
  gameImport_t / pmove_t / refexport_t / uiImport_t structs now hold __fp_<name> raw pointers plus inline
  forwarder member functions that supply the original defaults; call sites are unchanged.
  - Header restoration — disabled PCH and instead made g_headers.h, cg_headers.h, and common_headers.h      
  unconditionally include what the precompiled header used to drag in (b_local.h, g_functions.h, g_local.h,
  cg_local.h, Ravl/CVec.h, etc.).
  - Identifier collisions — renamed file-scope statics that clash with std::move / std::forward once
  <utility> becomes visible (move aiMove in five AI files, forward wp_forward in g_weapon.cpp). Other
  files use selective std:: qualification or explicit using std::*.
  - Mechanical fixestypename on dependent types in templates, const_cast<char*> for strchr/strrchr       
  overload changes, added missing int/size_t types where default-int was relied on, cast abs() arguments,
  spaced adjacent S_COLOR macro string literals to avoid UDL parsing, replaced afxres.h with winres.h.

  Build outputs (code/Debug/ and code/Release/):
  - jasp.exe — the SP client
  - jagamex86.dll — the game module

  Tasks: all five complete.

Was Claude perfect here? Not quite. First off, instead of using devenv /upgrade Claude went ahead and wrote 233 lines of code for a one-time migration script—in PowerShell no less! Hey, whatever gets the job done...

# One-shot converter from VS .NET 2003 .vcproj to modern .vcxproj for the
# Jedi Academy single-player source tree. Targets Win32 (x86) so it can link
# against the bundled 32-bit OpenAL/IFC libraries.
param(
	[Parameter(Mandatory=$true)] [string]$VcprojPath,
	[Parameter(Mandatory=$true)] [string]$VcxprojPath,
	[Parameter(Mandatory=$true)] [string]$Guid,
	[Parameter(Mandatory=$true)] [string]$RootNamespace,
	[string]$PchCreator = ''
)

$KeptConfigs = @('Debug|Win32', 'Release|Win32')

$ErrorActionPreference = 'Stop'

[xml]$proj = Get-Content -LiteralPath $VcprojPath
$projDir = Split-Path -Parent (Resolve-Path $VcprojPath)
$vcxprojDir = Split-Path -Parent (Resolve-Path -LiteralPath (New-Item -ItemType File -Path $VcxprojPath -Force).FullName)

function Normalize-Path {
	param([string]$p)
	if ([string]::IsNullOrWhiteSpace($p)) { return $p }
	$p = $p -replace '/', '\'
	while ($p.StartsWith('.\')) { $p = $p.Substring(2) }
	return $p
}

# Walk Files tree, returning objects { Path = <rel>, ExcludedFromBuild = @{ ConfigName = $true } }
$files = New-Object System.Collections.Generic.List[object]
function Walk-Files {
	param($node)
	foreach ($child in $node.ChildNodes) {
		if ($child.NodeType -ne 'Element') { continue }
		if ($child.LocalName -eq 'Filter') {
			Walk-Files $child
		} elseif ($child.LocalName -eq 'File') {
			$rel = Normalize-Path $child.GetAttribute('RelativePath')
			$entry = [pscustomobject]@{
				Path = $rel
				ExcludedFromBuild = @{}
			}
			foreach ($fc in $child.ChildNodes) {
				if ($fc.NodeType -ne 'Element') { continue }
				if ($fc.LocalName -eq 'FileConfiguration') {
					if ($fc.GetAttribute('ExcludedFromBuild') -eq 'TRUE') {
						$entry.ExcludedFromBuild[$fc.GetAttribute('Name')] = $true
					}
				}
			}
			$files.Add($entry) | Out-Null
		}
	}
}
Walk-Files $proj.VisualStudioProject.Files

# Classify
$cppFiles = @($files | Where-Object { $_.Path -match '\.(cpp|c)$' })
$hFiles   = @($files | Where-Object { $_.Path -match '\.(h|hpp|inl)$' })
$rcFiles  = @($files | Where-Object { $_.Path -match '\.rc$' })
$defFiles = @($files | Where-Object { $_.Path -match '\.def$' })

"Found {0} compile units, {1} headers, {2} rc, {3} def" -f $cppFiles.Count, $hFiles.Count, $rcFiles.Count, $defFiles.Count

# Emit a vcxproj
$cfgs = $proj.VisualStudioProject.Configurations.Configuration | Where-Object { $KeptConfigs -contains $_.GetAttribute('Name') }

$sb = New-Object System.Text.StringBuilder
$null = $sb.AppendLine('<?xml version="1.0" encoding="utf-8"?>')
$null = $sb.AppendLine('<Project DefaultTargets="Build" ToolsVersion="17.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">')

# ItemGroup: ProjectConfigurations
$null = $sb.AppendLine('  <ItemGroup Label="ProjectConfigurations">')
foreach ($cfg in $cfgs) {
	$name = $cfg.Name -replace '\|Win32', ''
	$null = $sb.AppendLine("    <ProjectConfiguration Include=`"$name|Win32`">")
	$null = $sb.AppendLine("      <Configuration>$name</Configuration>")
	$null = $sb.AppendLine("      <Platform>Win32</Platform>")
	$null = $sb.AppendLine("    </ProjectConfiguration>")
}
$null = $sb.AppendLine('  </ItemGroup>')

# Globals
$null = $sb.AppendLine('  <PropertyGroup Label="Globals">')
$null = $sb.AppendLine("    <ProjectGuid>{$Guid}</ProjectGuid>")
$null = $sb.AppendLine("    <RootNamespace>$RootNamespace</RootNamespace>")
$null = $sb.AppendLine("    <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>")
$null = $sb.AppendLine("    <Keyword>Win32Proj</Keyword>")
$null = $sb.AppendLine('  </PropertyGroup>')

$null = $sb.AppendLine('  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />')

# Per-config Configuration property group
foreach ($cfg in $cfgs) {
	$name = $cfg.Name -replace '\|Win32', ''
	$cfgType = if ($cfg.ConfigurationType -eq '2') { 'DynamicLibrary' } elseif ($cfg.ConfigurationType -eq '1') { 'Application' } else { 'Application' }
	$useDebugLibs = if ($name -eq 'Debug' -or $name -eq 'SHDebug') { 'true' } else { 'false' }
	$optimization = if ($name -eq 'Debug' -or $name -eq 'SHDebug') { 'Disabled' } else { 'MaxSpeed' }
	$null = $sb.AppendLine("  <PropertyGroup Condition=`"'`$(Configuration)|`$(Platform)'=='$name|Win32'`" Label=`"Configuration`">")
	$null = $sb.AppendLine("    <ConfigurationType>$cfgType</ConfigurationType>")
	$null = $sb.AppendLine("    <UseDebugLibraries>$useDebugLibs</UseDebugLibraries>")
	$null = $sb.AppendLine("    <PlatformToolset>v145</PlatformToolset>")
	$null = $sb.AppendLine("    <CharacterSet>MultiByte</CharacterSet>")
	$null = $sb.AppendLine("  </PropertyGroup>")
}

$null = $sb.AppendLine('  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />')
$null = $sb.AppendLine('  <ImportGroup Label="ExtensionSettings"></ImportGroup>')

foreach ($cfg in $cfgs) {
	$name = $cfg.Name -replace '\|Win32', ''
	$null = $sb.AppendLine("  <ImportGroup Label=`"PropertySheets`" Condition=`"'`$(Configuration)|`$(Platform)'=='$name|Win32'`">")
	$null = $sb.AppendLine('    <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists(''$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props'')" Label="LocalAppDataPlatform" />')
	$null = $sb.AppendLine("  </ImportGroup>")
}
$null = $sb.AppendLine('  <PropertyGroup Label="UserMacros" />')

# Per-config OutDir/IntDir/TargetName
foreach ($cfg in $cfgs) {
	$name = $cfg.Name -replace '\|Win32', ''
	$null = $sb.AppendLine("  <PropertyGroup Condition=`"'`$(Configuration)|`$(Platform)'=='$name|Win32'`">")
	$outDir = (Normalize-Path $cfg.OutputDirectory)
	$intDir = (Normalize-Path $cfg.IntermediateDirectory)
	if (-not $outDir.EndsWith('\')) { $outDir += '\' }
	if (-not $intDir.EndsWith('\')) { $intDir += '\' }
	$null = $sb.AppendLine("    <OutDir>$outDir</OutDir>")
	$null = $sb.AppendLine("    <IntDir>$intDir</IntDir>")
	$linker = $cfg.Tool | Where-Object { $_.Name -eq 'VCLinkerTool' }
	if ($linker.OutputFile) {
		$leaf = [System.IO.Path]::GetFileNameWithoutExtension($linker.OutputFile)
		$ext  = [System.IO.Path]::GetExtension($linker.OutputFile)
		$null = $sb.AppendLine("    <TargetName>$leaf</TargetName>")
		$null = $sb.AppendLine("    <TargetExt>$ext</TargetExt>")
	}
	$null = $sb.AppendLine("  </PropertyGroup>")
}

# Per-config ItemDefinitionGroup
foreach ($cfg in $cfgs) {
	$name = $cfg.Name -replace '\|Win32', ''
	$compiler = $cfg.Tool | Where-Object { $_.Name -eq 'VCCLCompilerTool' }
	$linker   = $cfg.Tool | Where-Object { $_.Name -eq 'VCLinkerTool' }
	$resource = $cfg.Tool | Where-Object { $_.Name -eq 'VCResourceCompilerTool' }

	$null = $sb.AppendLine("  <ItemDefinitionGroup Condition=`"'`$(Configuration)|`$(Platform)'=='$name|Win32'`">")
	$null = $sb.AppendLine("    <ClCompile>")

	$defines = ($compiler.PreprocessorDefinitions -replace ',', ';')
	if (-not $defines) { $defines = '' }
	if ($defines -and -not $defines.EndsWith(';')) { $defines += ';' }
	$defines += '%(PreprocessorDefinitions)'
	$null = $sb.AppendLine("      <PreprocessorDefinitions>$defines</PreprocessorDefinitions>")

	$incs = ($compiler.AdditionalIncludeDirectories -replace ',', ';')
	if ($incs) {
		if (-not $incs.EndsWith(';')) { $incs += ';' }
		$incs += '%(AdditionalIncludeDirectories)'
		$null = $sb.AppendLine("      <AdditionalIncludeDirectories>$incs</AdditionalIncludeDirectories>")
	}

	if ($name -eq 'Debug' -or $name -eq 'SHDebug') {
		$null = $sb.AppendLine("      <Optimization>Disabled</Optimization>")
		$null = $sb.AppendLine("      <RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>")
		$null = $sb.AppendLine("      <DebugInformationFormat>ProgramDatabase</DebugInformationFormat>")
	} else {
		$null = $sb.AppendLine("      <Optimization>MaxSpeed</Optimization>")
		$null = $sb.AppendLine("      <RuntimeLibrary>MultiThreadedDLL</RuntimeLibrary>")
		$null = $sb.AppendLine("      <FunctionLevelLinking>true</FunctionLevelLinking>")
		$null = $sb.AppendLine("      <IntrinsicFunctions>true</IntrinsicFunctions>")
	}
	$null = $sb.AppendLine("      <WarningLevel>Level3</WarningLevel>")
	$null = $sb.AppendLine("      <TreatWarningAsError>false</TreatWarningAsError>")
	$null = $sb.AppendLine("      <SDLCheck>false</SDLCheck>")
	$null = $sb.AppendLine("      <ConformanceMode>false</ConformanceMode>")
	$null = $sb.AppendLine("      <LanguageStandard>stdcpp14</LanguageStandard>")
	$null = $sb.AppendLine("      <LanguageStandard_C>stdc11</LanguageStandard_C>")
	$null = $sb.AppendLine("      <DisableSpecificWarnings>4244;4267;4305;4018;4101;4996;4146;4838;4477;4302;4311;4312;4291;4334</DisableSpecificWarnings>")
	$null = $sb.AppendLine("      <PrecompiledHeader>NotUsing</PrecompiledHeader>")
	# /Zc:forScope- restores the pre-standard for-loop scoping the original
	# 2003-era code relies on (loop counters live past the closing brace).
	$null = $sb.AppendLine("      <AdditionalOptions>/Zc:forScope- %(AdditionalOptions)</AdditionalOptions>")
	$null = $sb.AppendLine("    </ClCompile>")

	$null = $sb.AppendLine("    <Link>")
	if ($linker.AdditionalDependencies) {
		$deps = ($linker.AdditionalDependencies -replace ' ', ';')
		if (-not $deps.EndsWith(';')) { $deps += ';' }
		$deps += '%(AdditionalDependencies)'
		$null = $sb.AppendLine("      <AdditionalDependencies>$deps</AdditionalDependencies>")
	}
	if ($linker.OutputFile) {
		$of = Normalize-Path $linker.OutputFile
		$null = $sb.AppendLine("      <OutputFile>$of</OutputFile>")
	}
	if ($linker.SubSystem -eq '2') { $null = $sb.AppendLine("      <SubSystem>Windows</SubSystem>") }
	elseif ($linker.SubSystem -eq '1') { $null = $sb.AppendLine("      <SubSystem>Console</SubSystem>") }
	if ($linker.ModuleDefinitionFile) {
		$df = Normalize-Path $linker.ModuleDefinitionFile
		$null = $sb.AppendLine("      <ModuleDefinitionFile>$df</ModuleDefinitionFile>")
	}
	if ($linker.GenerateDebugInformation -eq 'TRUE') {
		$null = $sb.AppendLine("      <GenerateDebugInformation>true</GenerateDebugInformation>")
	} else {
		$null = $sb.AppendLine("      <GenerateDebugInformation>true</GenerateDebugInformation>")
	}
	if ($linker.GenerateMapFile -eq 'TRUE') {
		$null = $sb.AppendLine("      <GenerateMapFile>true</GenerateMapFile>")
	}
	if ($linker.StackReserveSize) {
		$null = $sb.AppendLine("      <StackReserveSize>$($linker.StackReserveSize)</StackReserveSize>")
	}
	$null = $sb.AppendLine("      <TargetMachine>MachineX86</TargetMachine>")
	$null = $sb.AppendLine("    </Link>")

	if ($resource -and $resource.PreprocessorDefinitions) {
		$null = $sb.AppendLine("    <ResourceCompile>")
		$rdefs = ($resource.PreprocessorDefinitions -replace ',', ';')
		if (-not $rdefs.EndsWith(';')) { $rdefs += ';' }
		$rdefs += '%(PreprocessorDefinitions)'
		$null = $sb.AppendLine("      <PreprocessorDefinitions>$rdefs</PreprocessorDefinitions>")
		$null = $sb.AppendLine("    </ResourceCompile>")
	}
	$null = $sb.AppendLine("  </ItemDefinitionGroup>")
}

# ItemGroup: ClCompile
$null = $sb.AppendLine('  <ItemGroup>')
foreach ($f in $cppFiles) {
	$null = $sb.AppendLine("    <ClCompile Include=`"$($f.Path)`">")
	$isC = $f.Path -match '\.c$'
	if ($isC) {
		$null = $sb.AppendLine("      <PrecompiledHeader>NotUsing</PrecompiledHeader>")
		# Files like the vehicle .c sources include C++-only headers (g_headers.h
		# pulls in STL containers via ghoul2_shared.h). The mp3code/* files in
		# the EXE project are genuine C and remain compiled as C below.
		$isPureC = $f.Path -match '(mp3code|smartheap|MatComp)\\' -or $f.Path -match '^MatComp\.c$'
		if ($isPureC) {
			$null = $sb.AppendLine("      <CompileAs>CompileAsC</CompileAs>")
		} else {
			$null = $sb.AppendLine("      <CompileAs>CompileAsCpp</CompileAs>")
		}
	}
	foreach ($cfg in $cfgs) {
		$name = $cfg.Name -replace '\|Win32', ''
		if ($f.ExcludedFromBuild.ContainsKey($cfg.Name)) {
			$null = $sb.AppendLine("      <ExcludedFromBuild Condition=`"'`$(Configuration)|`$(Platform)'=='$name|Win32'`">true</ExcludedFromBuild>")
		}
	}
	$null = $sb.AppendLine("    </ClCompile>")
}
$null = $sb.AppendLine('  </ItemGroup>')

# ItemGroup: ClInclude
$null = $sb.AppendLine('  <ItemGroup>')
foreach ($f in $hFiles) {
	$null = $sb.AppendLine("    <ClInclude Include=`"$($f.Path)`" />")
}
$null = $sb.AppendLine('  </ItemGroup>')

# ItemGroup: ResourceCompile
if ($rcFiles.Count -gt 0) {
	$null = $sb.AppendLine('  <ItemGroup>')
	foreach ($f in $rcFiles) {
		$null = $sb.AppendLine("    <ResourceCompile Include=`"$($f.Path)`" />")
	}
	$null = $sb.AppendLine('  </ItemGroup>')
}

# Module def files are in linker, not item group, but include for visibility
if ($defFiles.Count -gt 0) {
	$null = $sb.AppendLine('  <ItemGroup>')
	foreach ($f in $defFiles) {
		$null = $sb.AppendLine("    <None Include=`"$($f.Path)`" />")
	}
	$null = $sb.AppendLine('  </ItemGroup>')
}

$null = $sb.AppendLine('  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />')
$null = $sb.AppendLine('  <ImportGroup Label="ExtensionTargets"></ImportGroup>')
$null = $sb.AppendLine('</Project>')

Set-Content -LiteralPath $VcxprojPath -Value $sb.ToString() -Encoding UTF8
"Wrote $VcxprojPath"
Show moreShow less

I pushed back on Claude's __fp_<name> solution, where the pointer fields get renamed and inline forwarders carry the defaults. Modern MSVC won't accept default arguments on function pointers, and they're all over the place, including the game module plumbing to jagamex86.dll:

// Original
void (*trace)( trace_t *results, const vec3_t start, const vec3_t mins, const vec3_t maxs, const vec3_t end,
               const int passEntityNum, const int contentMask, const EG2_Collision eG2TraceType = (EG2_Collision)0, const int useLod = 0 );

// Claude's transform
void (*__fp_trace)( trace_t *results, const vec3_t start, const vec3_t mins, const vec3_t maxs, const vec3_t end,
                    const int passEntityNum, const int contentMask, const EG2_Collision eG2TraceType, const int useLod );

void trace( trace_t *results, const vec3_t start, const vec3_t mins, const vec3_t maxs, const vec3_t end,
            const int passEntityNum, const int contentMask, const EG2_Collision eG2TraceType = (EG2_Collision)0, const int useLod = 0 )
{
	__fp_trace( results, start, mins, maxs, end, passEntityNum, contentMask, eG2TraceType, useLod );
}

What I wanted instead was a pattern that was already right there in the source; there's even a comment from a developer on the Xbox port who ran into the same issue! I suppose the cl shipped with the XDK didn't accept default arguments on function pointers either. The workaround they shipped is quite elegant:

// Function objects to replace the function pointers used for trace in pmove_t
// We can't have default arguments on function pointers, but this allows us to
// do the same thing with minimal impact elsewhere.
struct Trace_Functor_t
{
	typedef void (*trace_func_t)(trace_t *, const vec3_t, const vec3_t, const vec3_t, const vec3_t,
								 const int, const int, const EG2_Collision, const int);
	trace_func_t trace_func;
	void operator()( trace_t *results, const vec3_t start, const vec3_t mins, const vec3_t maxs, const vec3_t end,
	                 const int passEntityNum, const int contentMask, const EG2_Collision eG2TraceType = (EG2_Collision)0, const int useLod = 0 )
		{ trace_func(results, start, mins, maxs, end, passEntityNum, contentMask, eG2TraceType, useLod); }
	const Trace_Functor_t &operator=(trace_func_t traceRHS)
	{
		trace_func = traceRHS;
		return *this;
	}
};

Claude's defense was that the Xbox build statically links the game library, which mattered for some reason? Eventually I got it to relent and implement the above pattern which worked without drama.

There's also /Zc:forScope-, which I'd have preferred Claude not reach for. It flips MSVC to pre-standard behaviour, where a counter declared in the loop preamble lives past the closing brace. The game code leans on this in plenty of places, including the saber damage tally:

// Annotated from code/game/wp_saber.cpp
// Linear search of the saber's victim list, appending if there's no match
for ( int i = 0; i < numVictims; i++ )
{
    if ( victimEntityNum[i] == trVictimEntityNum )
    {
        // already hit this guy before
        curVictim = i;
        break;
    }
}
// Without `/Zc:forScope-`...
// error C2065: 'i': undeclared identifier
if ( i == numVictims )
{
    // haven't hit his guy before
    if ( numVictims + 1 >= MAX_SABER_VICTIMS )
    {
        // can't add another victim at this time
        return;
    }
    // add a new victim to the list
    curVictim = numVictims;
    victimEntityNum[numVictims++] = trVictimEntityNum;
}

I would have loved to see it have a crack at this problem as it really is a pain to do by hand, but in the end this was a sensible call to guarantee identical behaviour.

Claude nuking the precompiled header also surprised me, but its rationale held up. Turns out UsePrecompiledHeader="3" is the old /YX auto-generate mode that modern MSVC dropped years ago; only /Yc and /Yu survive, and both want a literal match against the #include text. Jedi Academy's sources don't reliably provide that literal match, so Opus did what Opus does and pulled the plug on precompiled headers altogether.

Nitpicking aside, Claude spared me the kind of tedium where side projects go to die.