Skip to content

Instantly share code, notes, and snippets.

@Zinfidel Zinfidel/acui.lua
Last active Mar 23, 2020

What would you like to do?
PSX Armored Core radar and speedometer Lua script for BizHawk
Armored Core Lua Enhancements Script for BizHawk
By Zinfidel (Inspired by JAX's enhancements in his TAS)
For Armored Core 1.1 [SLUS-01323] [NTSC]
The Radar
Works similarly to the in-game radar, and is styled as such. Each enemy is displayed as a red square with
the enemy's health near it. If the enemy is above the player, a red line will extend down to a smaller
red square that represents the enemy's position at the same elevation as the player. If the player is above
an enemy, the enemy's box will have a red line extending UP to a smaller red box. Modify the
RADAR_SIZE and RADAR_RANGE constants to taste. RADAR_SIZE is the size in pixels of the radar window.
RADAR_RANGE is the distance in in-game units of the radar. These values are the same as the radar
range statistic for radars in the game.
The radar can optionally display a navigation point and boundary polygons. Change the NavPoint variable to
a coordinate pair (x and z), and a yellow line will extend from the player to that point on the radar.
For drawing boundaries, set the Boundaries variable to an array of arrays of coordinate pairs, each pair
representing a vertex in the polygon. There are some example boundaries near the Boundaries variable to
work off of. These boundaries can be useful when clipping out of bounds and navigating to important places.
The Speedometer
Adds a speedometer bar to the bottom of the game screen. Speed is measured in in-game units, which
is possibly meters per second. Text and an indicator is displayed below the speedometer which indicates
the current maximum ground-boost speed. The speedometer will turn red when moving at or faster than the
current maximum ground boost speed. Modify the BUFFER_OVERSCAN_LEFT and SPEED_FONT_SIZE constants
for your display configuration. It is set by default to display well with debug/mednafen and
framebuffer clipping turned on.
Notes on speed in Armored Core
The game implements strange limitations on maximum speed in the game. Maximum ground boost speed is
limited to 75, but only for cardinal directions. For intermediate directions, however, the maximum
ground boost speed is 106. My hypothesis on why this is is that the game actually determines how fast
your AC can move by allowing it to translate a certain amount in given X and Z directions, rather
than doing traditional velocity calculations. This means that your AC's ground boost speed in X and Z
directions are bounded to 75, but when you are moving in both directions, your combined speed is the
sum of the components, yielding 106 by simple trig/pythagorean math.
This leads to some interesting consequences. Namely is that, if you are traveling to a destination
in a cardinal direction, you can strafe left and right on the way to your destination as much as you
like (so long as you don't change your heading by more than 45 degrees) in a "zig-zag" pattern and you
will actually get there in eactly the same amount of time as a straight line. Conversely, if you are
traveling in an intermediate direction, any deviation from a straight line is actually even worse
than just the loss of efficiency from the pathing, since your lateral movement is also slower.
Y speed is not considered for the speedometer, and several types of movement in the game can go faster
than the maximum ground boost speed. Some of the faster legs (LN-501, etc.) move in bursts of speed,
and those bursts are generally faster than ground boost speed (though not overall since the other bursts
are slower, and the average is less than 75). The laser blade swings will also propel you forward very
quickly. Getting shot by rifles and knock-back imparting projectiles while in the air will also tend to
send lightweight ACs flying at a much faster rate than the ground boost limit.
-- Constants
YAW_FACTOR = (math.pi * 2) / 4096; -- Convert in-game rotation value to radians
C_TRANSPARENT = 0x00000000;
C_BLACK = 0xFF000000;
C_RED = 0xFFFF0000;
C_GREEN = 0xFF00FF00;
-- Addresses and Offsets
PLAYER_HEALTH = 0x001A2818; -- Address of player health
FRAME_COUNTER_1 = 0x00198804; -- Address of one of game's frame counters
FRAME_COUNTER_2 = 0x001AC81C; -- Address of another frame counter
HEALTH_OFFSET = 0x170; -- Offsets between player/enemy health addressses
ACTIVE_OFFSET = -0x160; -- Offset from health to active status
POS_X_OFFSET = -0x158; -- Offset from health to X position
POS_Y_OFFSET = POS_X_OFFSET + 0x2; -- Offset from health to Y position
POS_Z_OFFSET = POS_X_OFFSET + 0x4; -- Offset from health to Z position
POS_YAW_OFFSET = POS_X_OFFSET + 0xA; -- Offset from health to rotation
-- Global State
PT = -- Player Table
X=0, Y=0, Z=0, Yaw=0, LastX=0, LastY=0, LastZ=0,
VelX=0, VelY=0, VelZ=0, VelXZ=0,
LastVelX=0, LastVelY=0, LastVelZ=0, LastVelXZ=0
EnemyHealthAddresses = {};
for i = 1, 10, 1 do
EnemyHealthAddresses[i] = PLAYER_HEALTH + i * HEALTH_OFFSET;
EnemyTables = {};
for i = 1, #EnemyHealthAddresses, 1 do
EnemyTables[i] = { Health = 0, X = 0, Y = 0, Z = 0, Active=0 };
-- Update Functions
function UpdatePlayer()
PT.LastX = PT.X;
PT.LastY = PT.Y;
PT.LastZ = PT.Z;
PT.X = mainmemory.read_s16_le(PLAYER_HEALTH + POS_X_OFFSET);
PT.Y = mainmemory.read_s16_le(PLAYER_HEALTH + POS_Y_OFFSET);
PT.Z = mainmemory.read_s16_le(PLAYER_HEALTH + POS_Z_OFFSET);
local yawRaw = mainmemory.read_s16_le(PLAYER_HEALTH + POS_YAW_OFFSET);
PT.Yaw = yawRaw * YAW_FACTOR;
function UpdatePlayerVelocity()
PT.LastVelX = PT.VelX;
PT.LastVelY = PT.VelY;
PT.LastVelZ = PT.VelZ;
PT.LastVelXZ = PT.VelXZ;
PT.VelX = PT.X - PT.LastX;
PT.VelY = PT.Y - PT.LastY;
PT.VelZ = PT.Z - PT.LastZ;
PT.VelXZ = math.sqrt(PT.VelX^2 + PT.VelZ^2);
function UpdateEnemies()
for i = 1, #EnemyTables, 1 do
UpdateEnemy(EnemyHealthAddresses[i], EnemyTables[i]);
function UpdateEnemy(healthAddr, enemyTable)
enemyTable.Health = mainmemory.read_s16_le(healthAddr);
enemyTable.X = mainmemory.read_s16_le(healthAddr + POS_X_OFFSET);
enemyTable.Y = mainmemory.read_s16_le(healthAddr + POS_Y_OFFSET);
enemyTable.Z = mainmemory.read_s16_le(healthAddr + POS_Z_OFFSET);
enemyTable.Active = mainmemory.read_s16_le(healthAddr + ACTIVE_OFFSET);
function IsMapOpen(theMap)
if theMap ~= nil then
return pcall(function()
theMap.GetMouseX(); --hack for lack of closed property
return false;
RADAR_SIZE = 600; -- Size of the radar on a side in pixels
RADAR_RANGE = 22000; -- Range of the radar, in in-game units
if not IsMapOpen(MapCanvas) then -- Prevent opening new windows when refreshing the script
MapCanvas = gui.createcanvas(RADAR_SIZE, RADAR_SIZE);
MapCanvas.SetTitle("AC Radar (Range: " .. RADAR_RANGE .. ")");
function TransformPoint(x, z)
-- Move coordinate system so player is at origin.
local pointX = x - PT.X;
local pointZ = z - PT.Z;
-- Rotate point about origin equal to player yaw. Counterclockwise is positive.
local playerRot = -PT.Yaw;
local pointXRot = pointX * math.cos(playerRot) - pointZ * math.sin(playerRot);
local pointZRot = pointZ * math.cos(playerRot) + pointX * math.sin(playerRot);
-- Scale to our canvas size
pointXRot = pointXRot * RADAR_RANGE_SCALE;
pointZRot = pointZRot * RADAR_RANGE_SCALE;
-- Move to position relative to map center point
pointXRot = RADAR_SIZE/2+pointXRot;
pointZRot = RADAR_SIZE/2-pointZRot;
return pointXRot, pointZRot;
function GetEnemyRadarPoint(enemyTable)
return TransformPoint(enemyTable.X, enemyTable.Z);
function TransformRectangle(rect)
local ret = {};
for i, point in ipairs(rect) do
local xformX, xformZ = TransformPoint(point[1], point[2]);
ret[i] = {xformX, xformZ}
return ret;
local NavPoint = nil -- Set to nil to disable, otherwise a coordinate pair {x, y}
local Boundaries = nil; -- Set to nil to disable, otherwise set indices to collections of coordinate pairs {{x,y},{x,y},...}
-- Mop Up Chrome Remnants 2 Boundaries
-- Boundaries[1] = {{-21149, 1149}, {-11351, 1149}, {-11431, -6013}, {-21149, -6061} };
-- Boundaries[2] = {{-524,8432},{524, 8432},{524, 3574},{-524, 3183}};
-- Boundaries[3] = {{-475,5424},{475,5424},{475,4545},{-475,4545}};
-- Boundaries[4] = {{-8649,13649},{-1315,13649},{-1315,3851},{-8649,3851}};
-- Destroy Floating Mines Boundaries
-- Boundaries[1] = {{-7618, 12934}, {-5237, 12987}, {-5273, 12421}, {-7849, 12448} };
function DrawMap()
MapCanvas.DrawRectangle(20, 20, RADAR_SIZE - 40, RADAR_SIZE - 40, C_GREEN);
MapCanvas.DrawPie(-5, -5, RADAR_SIZE + 10, RADAR_SIZE + 10, 0, 360, C_BLACK, C_BLACK);
MapCanvas.DrawPie(0, 0, RADAR_SIZE, RADAR_SIZE, -45, 270, C_GREEN, C_GREEN);
MapCanvas.DrawArc(0, 0, RADAR_SIZE, RADAR_SIZE, 0, 360, C_GREEN);
-- Draw nav point line
if NavPoint ~= nil then
local navX, navY = TransformPoint(NavPoint[1], NavPoint[2]);
MapCanvas.DrawLine(RADAR_SIZE/2, RADAR_SIZE/2, navX, navY, C_YELLOW)
MapCanvas.DrawRectangle(navX-2, navY-2, 4, 4, C_WHITE, C_YELLOW);
-- Draw boundaries
if Boundaries ~= nil then
for _, bounds in ipairs(Boundaries) do
local xBounds = TransformRectangle(bounds);
MapCanvas.DrawPolygon(xBounds, 0, 0, C_RED);
-- Draw enemies
for i = 1, #EnemyTables, 1 do
if EnemyTables[i].Active ~= 0 then
local x, z = GetEnemyRadarPoint(EnemyTables[i]);
local y = (EnemyTables[i].Y - PT.Y) / 32;
MapCanvas.DrawRectangle(x-2, z-2, 4, 4, C_WHITE, C_RED);
MapCanvas.DrawLine(x, z, x, z+y, C_RED);
MapCanvas.DrawRectangle(x-5, z+y-5, 10, 10, C_WHITE, C_RED);
MapCanvas.DrawText(x, z+15, EnemyTables[i].Health, C_WHITE, nil, nil, nil, nil, "center", "middle");
BUFFER_OVERSCAN_LEFT = 0; -- Overscan in pixels. For debug/mednafen with no clipping, this will be 18.
SPEED_FONT_SIZE = 8; -- Size of speedometer text, in pixels. Increase for Pixel Pro mode.
VEL_LIMIT_C = 75; -- Speed limit in a cardinal direction
VEL_LIMIT_D = 106; -- Speed limit in a intermediate direction
SpeedStartX = BUFFER_OVERSCAN_LEFT + ((client.bufferwidth()- (VEL_LIMIT_D * 2)) / 2);
SpeedStartY = client.bufferheight() - 20;
LastFrame1 = 0;
LastFrame2 = 0;
function DrawSpeedometer()
-- Track lag frames using the game's built-in frame counters. We don't update velocity on lag frames because they will
-- evaluate to 0, since position isn't updating. Either frame counter isn't perfect though, and will still update on lag
-- frames occasionally. Updating against both frame counters seems to eliminate all hiccups.
local currentFrame1 = mainmemory.read_s16_le(FRAME_COUNTER_1);
local currentFrame2 = mainmemory.read_s16_le(FRAME_COUNTER_2);
if (currentFrame1 ~= LastFrame1 and currentFrame2 ~= LastFrame2) then
if (currentFrame2 ~= 0) then -- This address tends to be 0 during non-mission parts of the game.
local vel = math.abs(PT.VelXZ);
-- The maximum velocity is calculated by setting one component to 75 and the other component is calculated by player
-- rotation. The equation used is actually the Pythagorean theorem but simplifed a lot. Note that the max speed
-- value only considers the heading of your *camera*. This means that strafing or moving slightly off-axis of the
-- camera (which can happen quite frequently naturally) means you will exceed the calculated maximum speed. This
-- just means that you are traveling in a direction with a higher max speed than the direction your camera is facing.
local velMax = VEL_LIMIT_C * math.sqrt(math.sin(4*PT.Yaw)^2 + 1);
vel = math.floor(vel);
velMax = math.floor(velMax);
local velColor = vel >= velMax and C_RED or C_SPEEDOMETER;
gui.drawRectangle(SpeedStartX, SpeedStartY, VEL_LIMIT_D*2, 8, C_WHITE);
gui.drawRectangle(SpeedStartX, SpeedStartY, vel*2, 8, C_WHITE, velColor);
gui.drawText(SpeedStartX-20, SpeedStartY, "SPD " .. vel, C_WHITE, C_TRANSPARENT, 8);
gui.drawText(SpeedStartX + velMax*2+4, SpeedStartY+10, velMax .. "|", C_WHITE, C_TRANSPARENT, 8, nil, nil, "right");
LastFrame1 = currentFrame1;
LastFrame2 = currentFrame2;
while true do
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.