对1990年DOS版席德·梅尔铁路大亨的反向工程
Reverse Engineering Sid Meier's Railroad Tycoon for DOS from 1990

原始链接: https://www.vogons.org/viewtopic.php?t=105451

## 游戏中的火车动画:总结 本文详细介绍了游戏中火车动画的实现方式。动画出现在首次货物交付、新的速度记录和线路开通等事件中。即使是速度记录屏幕上的“静止”火车,实际上也是由动画火车初始帧构建而成。 所有动画资源都打包在单个PIC文件中,具体取决于场景(美国东部/西部、英国、欧洲)以及火车是否使用“现代”或“旧式”车厢。游戏根据引擎的推出年份与场景特定起始年份进行比较来确定车厢类型。 动画是使用“EngineInfo”结构中的数据构建的(每个引擎42字节),详细描述了车轮位置、偏移量和尺寸。游戏通过将车轮资源复制到基础引擎帧上来重建动画帧。车厢资源的处理方式类似,使用车厢类型ID来索引资源图片。 为蒸汽机车添加烟雾效果,使用由滴答计数和预定义帧索引确定的单独动画循环。端口使用OpenGL进行渲染,从这些帧创建纹理以实现高效显示,这与原始游戏的逐像素方法不同。调试显示了一个计划但未使用的“GP”系列柴油机,展示了不完整的资源实现。

黑客新闻 新的 | 过去的 | 评论 | 提问 | 展示 | 工作 | 提交 登录 反向工程1990年DOS版的席德·梅尔铁路大亨 (vogons.org) 16点 由 LowLevelMahn 3小时前 | 隐藏 | 过去的 | 收藏 | 3评论 帮助 pimlottc 2分钟前 | 下一个 [–] 图片链接似乎都坏了,但你可以在视频的1分10秒左右看到一些高分辨率地图。回复 marticode 9分钟前 | 上一个 | 下一个 [–] 我青少年时期在该游戏上花费了无数时间。我觉得与其反向工程,不如从头开始重写它可能更快。回复 LowLevelMahn 3小时前 | 上一个 [–] 开发者'Wilczek'正在这个Vogons论坛上发布铁路大亨反向工程的进展。回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

Dear Vogons,

Today we will look into train animations. You can see animated trains in the game when:
- a cargo is delivered the first time to somewhere (investor and financier difficulty)
- a new speed record occurs (the train is displayed on the "New Speed Record" screen)
- a service is inaugurated
- etc.

Now, you might say that the train on the "New Speed Records" screen is not animated, but, it is actually a still frame of an animated train (tick count: 0x3D), so technically it is an animated train 😀.

Where are the assets that will be used to draw the animation? It depends on the scenario and whether the engine pulls "modern cars" or "old cars". All the assets required to display an animated train are jammed into a single PIC file.
Eastern USA (scenario ID = 0) and Western USA (scenario ID = 1):
If the train pulls old cars then "locos.pic" is loaded:
IQRT-ddNaQXyTYziATU8bg3YAQRxylQ5kyvd0uUET2bJMu0?width=320&height=200
otherwise "locosm.pic":
IQS1PPa3SosFRJDozcAxLOPHARK5aKRqkcMvKugrBJuBR-c?width=320&height=200
England (scenario ID = 2):
If the train pulls old cars then "elocos.pic" is loaded:
IQRSv_OlL7sYT58YJrDP5hPuAUn-N4JopAaWdx4gs9Ymaj8?width=320&height=200
otherwise "elocosm.pic":
IQRfLiwFUIh5SLGXuQ2CJIi2AcEZ3VyIaLKtmRU7quU-6H8?width=320&height=200
Europe (scenario ID = 3):
If the train pulls old cars then "clocos.pic" is loaded:
IQRm4RodWtmGSIu_BnaEUZ5mAeeVzMt-_F__R_6upaOELYo?width=320&height=200
otherwise "clocosm.pic":
IQQc0bhsXt4GR7FDAGOMqa0JAVb0McMlBKbEY0AW9-nqHvA?width=320&height=200

Code:

02BB:F4F9  B82A00              mov  ax,002A // each engine is described by 42 bytes
02BB:F4FC F76E06 imul word [bp+06] ss:[FEAA]=0002 // engine index
02BB:F4FF 8BD8 mov bx,ax
02BB:F501 A17CBF mov ax,[BF7C] // modernCarsIntroductionYear, scenID=0001->1908 (0x0774)
02BB:F504 39872C02 cmp [bx+022C],ax // check engine introduction year, if < modernCarsIntroductionYear, then load simple ?locos.pic, otherwise ?locosm.pic; base=0212, offset=1A (already aliased)
02BB:F508 7D24 jge 0000F52E ($+24)
02BB:F50A 833E209502 cmp word [9520],0002 // scenario ID
02BB:F50F 7D09 jge 0000F51A ($+9)
02BB:F511 B80200 mov ax,0002
02BB:F514 50 push ax
02BB:F515 B8C938 mov ax,38C9 // points to: "locos.pic"
02BB:F518 EB36 jmp short 0000F550 ($+36)
02BB:F51A B80200 mov ax,0002
02BB:F51D 50 push ax
02BB:F51E 39062095 cmp [9520],ax // scenario ID
02BB:F522 7505 jne 0000F529 ($+5)
02BB:F524 B8D338 mov ax,38D3 // points to: "elocos.pic"
02BB:F527 EB27 jmp short 0000F550 ($+27)
02BB:F529 B8DE38 mov ax,38DE // points to: "clocos.pic"
02BB:F52C EB22 jmp short 0000F550 ($+22)
02BB:F52E 833E209502 cmp word [9520],0002
02BB:F533 7D09 jge 0000F53E ($+9)
02BB:F535 B80200 mov ax,0002
02BB:F538 50 push ax
02BB:F539 B8E938 mov ax,38E9 // points to "locosm.pic"
02BB:F53C EB12 jmp short 0000F550 ($+12)
02BB:F53E B80200 mov ax,0002
02BB:F541 50 push ax
02BB:F542 39062095 cmp [9520],ax // scenario ID
02BB:F546 7505 jne 0000F54D ($+5)
02BB:F548 B8F438 mov ax,38F4 // points to "elocosm.pic"
02BB:F54B EB03 jmp short 0000F550 ($+3)
02BB:F54D B80039 mov ax,3900 // points to "clocosm.pic"
02BB:F550 50 push ax
02BB:F551 E844DD call 0000D298_LoadLocos(fileName(in AX)) ($-22bc) // just loads the raw image
02BB:F554 83C404 add sp,0004

How do we know if an engine pulls modern cars or old cars? This can be calculated by the following way: if the engine's introduction year is greater or equal than (scenario start year / 2) + 975 then the engine is pulling modern cars, otherwise old cars.
For example in the European scenario any engine that is introduced in the year 1925 or after will pull modern cars:

// 02BB:F47C  A178DC              mov  ax,[DC78]              ds:[DC78]=076C // scenario start year!
// 02BB:F47F 99 cwd
// 02BB:F480 2BC2 sub ax,dx
// 02BB:F482 D1F8 sar ax,1
// 02BB:F484 05CF03 add ax,03CF
// 02BB:F487 A37CBF mov [BF7C],ax ds:[BF7C]=0785 // year 1925

All right, we have the assets picture loaded. How do we build the animation frames? To explain it we will use the "4-4-0 American" engine as an example. All the information is stored in the "EngineInfo" structure. Each engine is described by 42 bytes. Interesting fact: it seems the team planned to include 11 engines per scenario, but the 11th engine could not fit in the *locos*.pic file. Why do I think that? The SVE file contains an extra engine that never becomes available. This is the 'GP' Series Diesel engine. All the engine data is there, but there are no assets available...
IQRneUCoCNncTLs_KKsvpAuFAWmA3NjriR8Nv2c8OEWozzc?width=802&height=260
... except the asset for the "Engine Info" screen. Since I have implemented the Engine Info screen as well 😀 I can show it to you what the "missing" engine looks like:

IQSKFrgQTs_ZR7VdvJeYGiiSAfySgfQeI9joICqu-CfeMKI?width=660

I could figure out the meaning of 40 out of 42 bytes of the EngineInfo structure, so the format is:
0 00 T
1 01 r
2 02 a
3 03 i
4 04 n
5 05 N
6 06 a
7 07 m
8 08 e
9 09
10 0A c
11 0B h
12 0C a
13 0D r
14 0E a
15 0F c
16 10 t
17 11 e
18 12 r
19 13 s
20 14 maximumSpeed (0x14-0x15)
21 15
22 16 power (divided by 500) (0x16-0x17)
23 17
24 18 price (divided by 1000) (0x18-0x19)
25 19
26 1A yearOfIntroduction (0x1A-0x1B)
27 1B
28 1C smokeAttachmentX (0x1C-0x1D) also used to determine if the engine is a steam engine (if not this value is -1)
29 1D
30 1E smokeAttachmentY (0x1E-0x1F)
31 1F
32 20 engineAssetWidth
33 21 xOffsetOfAnimatedWheelsOfLeftFrames
34 22 yOffsetOfAnimatedWheelsOfTopFrames
35 23 x1OffsetOfAnimatedWheelsOfLeftFrames
36 24 y1OffsetOfAnimatedWheelsOfTopFrames
37 25 xOffsetOfAnimatedWheelsOfRightFrames
38 26 yOffsetOfAnimatedWheelsOfBottomFrames
39 27 wheelPositionX
40 28 wheelPositionY
41 29 unknown
42 2A unknown

Bytes from 0x1C-0x28 are needed to build the animation frames. There are always 4 animation frames regardless the number of wheel frames in the assets picture. (For example, the Grasshopper engine only has 2 wheel frames, but the engine info struct will tell us to reuse frame 0 as frame 2 and frame 1 as frame 3.) The complete engine picture in the assets picture is the base frame. The wheels are scattered around in the big asset picture, wherever they fit. It does not really matter, because from the data stored in the engine info structure we can reconstruct all the animation frames. Interesting trivia: the wheels of the "Mallet" engine are not animated, most probably because its animated wheels did not fit in the assets picture.

So in the case of the "4-4-0 American" engine the values are:

0x00..0x13	trainName	4-4-0 American     \0
0x14..0x15 maximumSpeed (divided by 5) 8
0x16..0x17 power (divided by 500) 3
0x18..0x19 price (divided by 1000) 30
0x1A..0x1B yearOfIntroduction 1846
0x1C..0x1D smokeAttachmentX 59
0x1E..0x1F smokeAttachmentY 52
0x20 engineAssetWidth 73
0x21 xOffsetOfAnimatedWheelsOfLeftFrames 90
0x22 yOffsetOfAnimatedWheelsOfTopFrames 77
0x23 x1OffsetOfAnimatedWheelsOfLeftFrames 123
0x24 y1OffsetOfAnimatedWheelsOfTopFrames 85
0x25 xOffsetOfAnimatedWheelsOfRightFrames 125
0x26 yOffsetOfAnimatedWheelsOfBottomFrames 87
0x27 wheelPositionX 31
0x28 wheelPositionY 63

The "4-4-0 American" is the 3rd engine, so its engine index is 2. To get the Y coordinate of the row in which the base image is we can use the following formula: assetY = (engineIndex & 7 /*because 8 engines fit in 1 column*/) * 0x18 /*height of the row*/;
The starting X coordinate of the engine is 0 if we are taking an engine from the first column (engineIndex <= 7), otherwise 160.

The original game defines a fixed height for each engine within a row, which is 0x15=21 pixels (02BB:F557 B81500 mov ax,0015). Therefore, the original game chops off the top 3 pixels of each row. This is definitely incorrect, because the top pixels of the "Mallet" engine picture are chopped off too, so the original should have only got rid of 1 or max 2 rows of pixels.

The wheel width can be calculated as x1OffsetOfAnimatedWheelsOfLeftFrames - xOffsetOfAnimatedWheelsOfLeftFrames + 1. So in the case of the "4-4-0 American" engine the animated wheel width is 123-90=33 pixels. (Note: in the assets picture there are 34 pixels present from the wheel, but only 33 are used)
The height of the wheel can be calculated as y1OffsetOfAnimatedWheelsOfTopFrames - yOffsetOfAnimatedWheelsOfTopFrames + 1, which in the case of the "4-4-0 American" engine is 85-77+1=9 pixels.

To create a frame, we need to copy the pixels of the animated wheels to the copy of a base frame. The start location where the pixels should be copied to is given by the wheelPositionX and wheelPositionY values, however, they are absolute coordinates. Now we need to discuss a major difference between the port and the original. The original at a very high level does the drawing in a way that it draws the base frame, then restores the background pixels where the animated wheels would go and then draws the animated wheel pixels then the final pixels of the train are copied to the "frame/front buffer".
Since I use OpenGL I create each frame separately as an RGBA image and then I create textures from them (which will live in the graphics card's memory). When I draw the frames I just tell OpenGL to use texture "n" to draw the animated engine frame (where "n" is the frame index). I don't need to restore background pixels and then copy wheel pixels to the restored location. It would be pretty inefficient anyway to do so.
OK, so, the wheel positions need to be transformed to relative coordinates. This is easy, the "wheelPositionX" modulo 160 gives the relative wheel position X and the "wheelPositionY" modulo 24 (row height per train) - 3 (the number of chopped off pixels) gives the relative wheel position Y coordinate. Now we only need to copy the wheel pixels from the big assets image to the base engine image that was previously copied to a memory buffer.

C++ code from the port doing all these to demonstrate the above written logic:

// 02BB:F55B-F561:
const auto engineInfo = this->configuration.GetEngineInfo(engineIndex);

// 02BB:F557 B81500 mov ax,0015 // image height, but chops off the top pixel! (tested with Mallet!)
const auto height = 0x15;

// 02BB:F563-02BB:F56E:
const int32_t width = engineInfo.GetEngineAssetWidth() % 160;

// 02BB:F567-02BB:F57C:
const int32_t assetY = (engineIndex & 7) * 0x18 + 3; // 7 engines fit in one column

// 02BB:F580-02BB:F586:
const int32_t assetX = (engineIndex > 7) ? 160 : 0;

const auto texWidth = this->ClosestPowerOf2(width);
const auto texHeight = this->ClosestPowerOf2(height);
std::vector<uint8_t> pixels(texWidth * texHeight * 4);
this->CopyRGBAPixels(image.ImageBytes, pixels,
assetX, assetY, image.Width, image.Height,
0, 0, texWidth, texHeight,
width, height);

// DEBUG>>LocalStorage::Instance().WriteAll(L"raw.raw", pixels);
auto baseTexture = ::RRTLIB::CreateTextureFromDecompressedRGBAPIC(::RRTLIB::DecompressedImage(pixels, texWidth, texHeight));

// create the frames>>
const auto wheelWidth = engineInfo.GetX1OffsetOfAnimatedWheelsOfLeftFrames() - engineInfo.GetXOffsetOfAnimatedWheelsOfLeftFrames() + 1;
const auto wheelHeight = engineInfo.GetY1OffsetOfAnimatedWheelsOfTopFrames() - engineInfo.GetYOffsetOfAnimatedWheelsOfTopFrames() + 1;

std::vector<t_texture_2D_ptr> animationFrames;
animationFrames.reserve(4);

// 02BB:F597-F639:
for (size_t frameIndex = 0; frameIndex < 4; ++frameIndex) {
auto frame = pixels;

if (wheelWidth > 1) {
const auto wheelAssetX = (!(frameIndex & 1)) ? engineInfo.GetXOffsetOfAnimatedWheelsOfLeftFrames() : engineInfo.GetXOffsetOfAnimatedWheelsOfRightFrames();
const auto wheelAssetY = (frameIndex <= 1) ? engineInfo.GetYOffsetOfAnimatedWheelsOfTopFrames() : engineInfo.GetYOffsetOfAnimatedWheelsOfBottomFrames();

const auto wheelAttachPosX = (engineInfo.GetWheelPositionX() % 0xA0);
const auto wheelAttachPosY = (engineInfo.GetWheelPositionY() % 0x18) - 3;

this->CopyRGBAPixels(image.ImageBytes, frame,
wheelAssetX, wheelAssetY, image.Width, image.Height,
wheelAttachPosX, wheelAttachPosY, texWidth, texHeight,
wheelWidth, wheelHeight);
}
// DEBUG>>LocalStorage::Instance().WriteAll(std::to_wstring(engineIndex) + std::wstring(L"_engine_frame_") + std::to_wstring(frameIndex) + std::wstring(L".raw"), frame);

animationFrames.push_back(::RRTLIB::CreateTextureFromDecompressedRGBAPIC(::RRTLIB::DecompressedImage(frame, texWidth, texHeight)));
}

The final frames of the "4-4-0 American" engine are:
IQRWA5pli7BmQ7Nn47XEi7PEAT6XGBEUCb2AcWgHXqWHJAc?width=128&height=32
IQR27TUCggn-RJ70IEZvdOBmAVYGluqFdBHH-zQnTfGfACE?width=128&height=32
IQRdi4tCW8TpRYRtfb3bOtf8ASTvEAoUavxZI7Vxd-YyiqI?width=128&height=32
IQSkUV2qoEFWTaFb2XHMy7zrAXx5k0MqWzJEJnMWbVPAjCw?width=128&height=32

The smoke assets are always in a fixed location in the assets picture, which means it does not matter if the *locos.pic or the *locosM.pic file is loaded. The smoke asset width is fixed for each frame which is 0x004E (78) pixels and the height is also fixed: 0x000E (14) pixels. The width of a smoke "cell" is 0x0050 (80) pixels and the height is 0x000F (15) pixels. I just take the smoke assets and create 4 textures from them using the purple color as the transparent color.

To get the proper car asset is a bit trickier, because not all car type IDs map to an asset. For example the car type ID for the mail car is 0, but the passenger car's type ID is 3. Unused car type IDs are: 1, 2 (well, it is used to indicate a caboose car), 4, 5 and 15.
The car type IDs mapped to car names in the USA scenarios are:
0 = "Mail"
1 = ""
2 = ""
3 = "Passengers"
4 = ""
5 = ""
6 = "Food"
7 = "LiveStock"
8 = "Mfg.Goods"
9 = "Grain"
10 = "Paper"
11 = "Steel"
12 = "Petroleum"
13 = "Wood"
14 = "Coal"

Let me show the assets picture here again, you can see that a passenger car (car type ID=3) is next to a mail car (car type ID=0):
IQS1PPa3SosFRJDozcAxLOPHARK5aKRqkcMvKugrBJuBR-c?width=320&height=200

If we use an index variable that only increases when a car type ID is used, then we get an index that can be used to calculate the asset coordinates for a car type ID. If this index is even then the we pick a car asset from the 1st column, if it is odd then from the 2nd column. The row of the car asset can be determined by integer dividing the index by 2.
Knowing all this it is easy now to write code that would copy the car assets from the big assets picture. In the port I create a 8x8 black image as a placeholder for unused car type IDs, so when rendering I can use the car type ID as an index into a an array holding texture IDs of the cars. One important thing that needs to be considered: if a car is an old car then its width is 0x2F (47) pixels, otherwise 0x3C (60) pixels. The height is fix: 0x13 (19) pixels.
In code:

// NOTE: for simplicity we do a 1:1 mapping of car types and images. Unused car type slots will be black images
t_texture_2D_ptr placeholder(new FSYSTEM::OPENGL::CGLTexture2D_Black());

const auto width = (assumeModernCars) ? 0x3C : 0x2F;
const auto height = 0x13; // 02BB:F658

int16_t carImageIndex/*bp-06*/ = 0;
for (int carType = 0; carType < 15; ++carType) {
// 02BB:F646-02BB:F656:
if ((carType < 6) && (!((carType == 0) || (carType == 3)))) {
result.push_back(std::shared_ptr<SimpleAssetInfo>(new SimpleAssetInfo(placeholder, 8, 8, 1, 1)));
continue;
}

const auto texWidth = this->ClosestPowerOf2(width);
const auto texHeight = this->ClosestPowerOf2(height);
std::vector<uint8_t> pixels(texWidth * texHeight * 4);

// reimplementation of code 02BB:F658-02BB:F691:
const int32_t y = (carImageIndex / 2 /*row determination*/) * 0x14 /*car cell height*/ + 0x50 /*to skip 2 engines + smoke assets*/;
const int32_t x = (carImageIndex & 1 /*left or right column*/) * 0x50 + 160 /*offset of the left column of cars in the large image*/;

this->CopyRGBAPixels(image.ImageBytes, pixels,
x, y, image.Width, image.Height,
0, 0, texWidth, texHeight,
width, height);

// DEBUG>> LocalStorage::Instance().WriteAll(std::wstring(L"car_") + std::wstring(assumeModernCars ? L"m_" : L"") + std::to_wstring(carType) + std::wstring(L".raw"), pixels);

result.push_back(std::shared_ptr<SimpleAssetInfo>(
new SimpleAssetInfo(::RRTLIB::CreateTextureFromDecompressedRGBAPIC(::RRTLIB::DecompressedImage(pixels, texWidth, texHeight)),
width, height,
static_cast<float>(width) / static_cast<float>(texWidth),
static_cast<float>(height) / static_cast<float>(texHeight))));

++carImageIndex;
}

Now that we have the animated engine frames and the car assets it is easy to render an animated train. BUT, we still have to deal with the smoke! First of all, the smoke only needs to be rendered if the engine is a steam engine. It can be easily determined by looking at the smokeAttachmentX value in the engine info structure (offset: 0x1C-0x1D). If this value is -1 (0xFFFF) then the engine is NOT a steam engine. In this case we have nothing else to do. Otherwise, we need to figure out where to draw it. The smokeAttachmentX and smokeAttachmentY values are absolute coordinates within the big assets picture. However, we need the relative coordinates because we have the engine asset already "taken out" from the big assets picture. This can be done by writing (simplified code!):
relativeSmokeAttachmentX = engineInfo.smokeAttachmentX % 0xA0 - 0x4A /*smoke asset width-offset*/;
relativeSmokeAttachmentY = engineInfo.smokeAttachmentY % 0x18 - 0x13+1;

Now that we have the relative coordinates, just draw the smoke frame relative to the engine's position on the screen. By the way, how do we know which smoke asset to draw? There is an animation tick count that determines the frame index. The tick length in milliseconds is not fixed. The original uses vertical retrace waits to modify the tick count. For example, when a train needs to slow down as it arrives to a station is done by the original game by simply waiting more and more vertical retraces. In the port I sum up the elapsed time and if it is above the expected limit (n*vertTraceTime) then I increase the tick count and subtract the limit from the elapsed time.
The smoke frames are not in sync with the wheel frames. The original uses an array with fixed smoke frame indices:

// 17ED:11C0: smoke frame indices
// 17ED:11C0 = 6 words, 17ED:11C0 00 00 03 00 01 00 03 00 02 00 03 00

The following formula is used to calculate an index that can be used to get the final smoke frame index: (animationTickCount / 3) % 6. I use the same logic in the port, obviously.
In code (for better understanding):

// 17ED:11C0: smoke frame indices
// 17ED:11C0 = 6 words, 17ED:11C0 00 00 03 00 01 00 03 00 02 00 03 00
static int16_t smokeFrameIndices[] = { 0, 3, 1, 3, 2, 3 };
...
const auto smokeAsset = this->assetHandler->GetSmokeAsset(smokeFrameIndices[(this->animationTickCount / 3) % 6]);
...

Finally, here is an image of the port showing a "train arriving at a station" animation. You can see that here I could also use resolution independent rendering, it is pretty nice (click for a larger image):
IQT8e-3LZeluTbKyNXKvUWTqAYgCzW_tgYRkLmeqBmNfdlA?width=660

And here is a VIDEO showing the first cargo delivery and the new speed record screens where train animations are involved.

I hope you enjoyed this little writeup. Next time we will look at something boring, like stocks and chart rendering. Or, if you want, we can dig into the details of the first cargo delivery screen, because there was a nasty bug in the original game that has been fixed in the port 😉.

Best regards,
Wilczek (Zoltan Farkas)

联系我们 contact @ memedata.com