Xây dựng Z-Machine bằng ngôn ngữ lập trình "không phù hợp" nhất: Elm
Bài viết chia sẻ hành trình thú vị của tác giả khi xây dựng một trình giả lập Z-Machine (máy ảo dành cho game phiêu lưu văn bản thập niên 80) bằng ngôn ngữ Elm. Dù Elm là ngôn ngữ lập trình hàm thuần túy với dữ liệu bất biến — dường như đối lập hoàn toàn với yêu cầu truy cập bộ nhớ trực tiếp của Z-Machine — tác giả đã chứng minh được khả thi của dự án nhờ các cấu trúc dữ liệu tối ưu của Elm.

Z-Machine là một máy ảo ra đời vào đầu những năm 1980 do Infocom phát triển, cho phép họ biên dịch các trò chơi phiêu lưu văn bản (text adventure) một lần và chạy trên nhiều kiến trúc máy tính khác nhau. Đây là một giải pháp thông minh: thay vì phải đối mặt với cơn ác mộng biên dịch 10 game cho 10 kiến trúc phần cứng khác nhau, họ chỉ cần biên dịch 10 máy ảo Z-Machine và 10 game. Số lượng công việc này sẽ tăng theo cấp số nhân khi thêm nhiều kiến trúc và game hơn.
Màn hình trình giả lập Elm Frotz
Ngày nay, có nhiều trình giả lập Z-Machine hiện đại được xem như một phần của khảo cổ học phần mềm, hay những bức thư tình gửi đến thời đại của các trò chơi phiêu lưu văn bản. Tác giả bài viết luôn muốn tự xây dựng một phiên bản riêng và nay đã thực hiện được điều đó bằng ngôn ngữ lập trình Elm.
Tại sao Elm là lựa chọn "tồi tệ" nhất?
Z-Machine là một máy ảo truy cập bộ nhớ trực tiếp (direct memory access) với ngăn xếp riêng, được thiết kế để chạy trên các máy tính khiêm tốn. Do đó, ngôn ngữ tồi tệ nhất để triển khai nó chính là một ngôn ngữ mà mọi cấu trúc dữ liệu đều bất biến (immutable) và không có hàm nào gây ra tác động phụ (side-effects) — một ngôn ngữ lập trình hàm thuần túy như Elm.
Để hình dung sự điên rồ của ý tưởng này: hãy tưởng tượng bạn lưu bộ nhớ dưới dạng một Mảng byte (Array of bytes). Trong một ngôn ngữ không thuần túy, bạn có thể có một hàm nhận bộ nhớ, địa chỉ và giá trị, sau đó ghi giá trị đó vào bộ nhớ. Nhưng trong Elm, chúng ta không thể làm điều đó.
Thay vào đó, bạn phải có một hàm nhận các tham số tương tự nhưng trả về một bộ nhớ mới với byte đã được thay đổi. Tham số ban đầu không bị ảnh hưởng. Đối với một trình giả lập Z-Machine, điều này giống như sao chép dữ liệu liên tục, dẫn đến thảm họa về hiệu suất và bộ nhớ — chỉ để thay đổi một byte.
Vượt qua thách thức với cấu trúc dữ liệu bền vững
Ban đầu, tác giả nghĩ rằng dự án sẽ không khả thi nếu không có nhiều cấu trúc dữ liệu thông minh. Tuy nhiên, thực tế cho thấy Elm đã hỗ trợ sẵn sàng. Các mảng trong Elm được hỗ trợ bởi một cấu trúc dữ liệu bền vững (persistent data structure) — một biến thể của cây RRB trie — cho phép hiệu suất tốt hơn nhiều khi cắt và nối dữ liệu.
Một bài kiểm tra ban đầu dường như đã chứng minh điều này, vì vậy tác giả tiếp tục tiến hành và tự trấn an rằng mình không điên rồ như mình nghĩ.
Giao diện lập trình với Elm
Sau vài tuần viết các bài kiểm tra, vật lộn với thông số kỹ thuật của Z-Machine (riêng phần mã hóa văn bản đã mất vài ngày để hiểu), và cố gắng tìm các mẫu bất biến để hỗ trợ các thao tác có thể thay đổi, tác giả đã có một Z-Machine hoạt động. Nó có thể chạy một trò chơi .z3 của Infocom (phiên bản 3, phiên bản phổ biến nhất) và vượt qua bài kiểm tra tuân thủ Czech dành cho Z-Machines.
Mặc dù một số phần không quá đẹp mắt, nhưng nó hoạt động và có hiệu suất đủ tốt để trở thành một cách khả thi để xây dựng một trình phát fiction tương tác tốt — trên trình duyệt hoặc nơi khác.
Thiết kế API đơn giản cho Client
Mục tiêu chính của dự án là thực hiện một số thử nghiệm client thú vị (như if-pal). Để việc này dễ dàng hơn, tác giả đã cố gắng cung cấp cho thư viện một giao diện sạch sẽ để thực hiện các bước và xử lý sự kiện.
Dưới đây là cách hoạt động của API trong Elm:
Chỉ cần một dòng để tải tệp .z3 và nhận về một ZMachine:
ZMachine.load : Bytes -> Result String ZMachine
Vì không có vòng lặp vô hạn trong Elm, chúng ta chạy máy trong số lượng lệnh tối đa và mong đợi nó thông báo nếu chưa hoàn thành. Chúng ta nhận lại máy đã cập nhật và một StepResult:
ZMachine.runSteps : Int -> ZMachine -> StepResult
Một StepResult cung cấp kết quả của một bước/nhiều bước, danh sách các sự kiện đầu ra và một máy mới:
type StepResult
= Continue (List OutputEvent) ZMachine
-- chúng ta đã chạy các bước, nhưng còn nhiều bước cần chạy
| NeedInput InputRequest (List OutputEvent) ZMachine
-- chúng ta cần nhận một số đầu vào từ người dùng
-- sau khi có nó, gọi ZMachine.provideInput
| NeedSave Snapshot (List OutputEvent) ZMachine
-- người dùng đã yêu cầu lưu snapshot này
-- sau khi xong, gọi ZMachine.provideSaveResult
| NeedRestore (List OutputEvent) ZMachine
-- người dùng muốn tải một snapshot
-- sau khi xong, gọi ZMachine.provideRestoreResult
| Halted (List OutputEvent) ZMachine
-- chúng ta đã xong
| Error ZMachineError (List OutputEvent) ZMachine
-- ôi không
Một OutputEvent có thể là một trong các loại sau — các loại chính cần xử lý là PrintText, NewLine và ShowStatusLine:
type OutputEvent
= PrintText String
| NewLine
| ShowStatusLine StatusLine
| SplitWindow Int
| SetWindow Window
| EraseWindow Int
| SetCursor Int Int
| SetBufferMode Bool
| PlaySound Int
Tác giả cho rằng đây là một giao diện rất đơn giản với đủ chức năng để xây dựng một client tốt. Có nhiều chi tiết hơn trên trang github và trong kho lưu trữ có một ứng dụng node.js/elm mẫu cho thấy cách sử dụng nó với một bản sao của Zork1. Nếu bạn từng muốn xây dựng client Infocom của riêng mình — và thực sự, ai mà không muốn chứ? — dự án này sẽ giúp bạn đi một chặng đường dài.



