Skip to main content

19 posts tagged with "learning note"

View All Tags

· 13 min read
Wei Ji

前情提要

隨著 Polis 專案的串流功能完成,開發開始往下一個階段發展,PoC 還剩下兩個功能需要實作:

  • 基本遊戲系統
  • P2P 連線

因為 P2P 連線還需要解決遊戲狀態同步的問題,因此它在某種程度上仰賴遊戲系統,所以遊戲系統的實作就是我下一個階段要處理的事情。而遊戲系統預計要使用 ECS (Entity Component System) 實作則是很早就決定的事。

ECS (Entity Component System)

關於 ECS 的具體細節本文就不再贅述,簡言之就是使用 ECS 架構的遊戲在做職責分離的時候會跟物件導向 (OOP) 中抽象化的思考方式不相同,這對我而言是一個挑戰,因為我沒有真正用 ECS 架構去寫遊戲過。

最早會接觸 ECS 這個新鮮玩意兒是因為工作的關係接觸了 React Game Engine 但是它只是 ECS 的弱實現 (loose implementation),因此與實際 ECS 的思考方式之間還是存在落差,加上同事其實跟我提過我的寫法很大程度還是一般的 React 程式,並沒有發揮 ECS 的特性。

另外一個困擾我的點是,部分 Javascript/Typescript 實作的 ECS 函式庫著重於 ECS 中「資料緊湊」的特性,它們使用 TypedArray 定義的基本型態來儲存資料,這使得它們的原始設計與 Three.js 或 Rapier 這種有自己定義 class 的函式庫之間不好協調。

因此我認為在正式引入 ECS 這個技術棧 (Tech Stack) 到 Polis 以前應該先寫一個短期專案對選定函式庫的可行性做驗證,以及累積足夠的「ECS 思維」。

aimAndShoot

aimAndShoot 便是我的實驗對象。在「底火的芬芳 - 專案起源」一文中有提到過這個 repository,它是一個有點像 diep.io 的簡單小遊戲,作為重構改寫的對象是再適合不過了。

Miniplex

Javascript/Typescript 中有不少 ECS 套件,雖然跟一般的熱門套件相較之下都算是冷門套件。

正如我提過得,它們之中的大部分著重於資料緊湊的設計,或是有著不支援/不友善 Typescript 的問題,另外一點則是一些套件更預設了一套框架,開發者必須在這個架構下開發。

相較之下 Miniplex 更深刻的體現了「一次做好一件事」的哲學,讓我們來看看它的介紹:

A 👩‍💻 developer-friendly entity management system for 🕹 games and similarly demanding applications, based on 🛠 ECS architecture.

是的,它只把自己定位在「Entity Management」的部份,既不管資料緊不僅湊,也不管開發者想怎麼實作 System,即便看著範例程式碼,我也不會浮現「這玩意兒到底要怎麼跟 Three.js 或 Rapier 整合?」的問題,我只需要煩惱如何把問題使用 ECS 的架構去實現就好。

重構

很快就完成了從 Javascript 轉換成 Typescript 的重構 (2023-09-13~2023-09-15),這個部份應該算是整個重構中最簡單的,畢竟遷移 Legacy Code 我也不是第一次幹了,有一些工具可以協助完成這種事:

Web Component (Custom Element) 初體驗

微前端 (Micro frontends) 其實也不是什麼新鮮的玩意了,不過一直沒有機會或動機去使用它。第一次知道這個東西是為了解決客戶的(相對)大型專案而查資料查到的,只是客戶方面的技術相對落後(沒有 CI/CD、整合仰賴客戶的工程師手動複製程式碼、預計使用 iframe 作為大型專案的 solution...),而公司作為代工方並沒有技術決策的權限,自己本身也沒有 side project 有面臨類似的問題。

作為一個網頁前端工程師,UI 之類的問題我一律想用 React 解決,在 Canvas 上刻 UI 根本浪費時間效果又差。但是這個 ECS 的練習我並不想直接讓它跟 React App 耦合,發現這似乎是使用 Web Component 這個 Web API 的好時機。

於是一開始 (2023-09-16) 我先根據我預想的排版用 React 實作,再把它包裝成 Custom Element,透過一個 callback (EventListener) 當 Canvas 被掛上 DOM 之後回傳 HTMLCanvasElement ,再用遊戲的實例去接收它來繪製遊戲內容。

當我把遊戲邏輯實作的差不多之後 (2023-09-20),發現應該反過來操作:

把遊戲封裝成 Custom Element 給 React 使

因為整個遊戲其實只仰賴一塊 Canvas 作為 I/O,只要能夠恰當的完成初始化跟釋放資源,就能被封裝到 React 的 Cycle life 內才對。

另一方面也是因為我已經在 React 內寫過諸如 Three.js 和 MapBox GL JS 的東西,對於這種「非 React 原生的套件如何整合進 React App 內」已經很直覺的知道怎麼做了:用 ref 取得實例並搭配 useEffect 來把事件掛上去跟釋放 callback。

ECS

Miniplex 的 Typescript 友善程度算是比我預期的的還好,關於 Entity 的型別如何聲明,Github 上面有一則討論算是給出了蠻明確的方向1

type IPosition {
position: Vector3
}

type IHealth {
health: { current: number, max: number }
}

type Entity = Partial<IPosition & IHealth & ...>

我個人是使用類似但是有一點差異的方式:

export type AgentEntity = {
id: string;
particle: ParticleComponent;
health: HealthComponent;
projectileEmitter: ProjectileEmitterComponent;
warrior: WarriorMiscComponent;
brain?: DejavuComponent;
statistics: WarriorStatisticsComponent;
};

export type BulletEntity = {
particle: ParticleComponent;
attackEffect: AttackEffectComponent;
};

export type Entity = Partial<
TimeEntity & EventEntity & AgentEntity & BulletEntity
>;

以 ECS 的思考模式,其實並不在乎 Entity 要多具體,而是以 Component 為操作單元,而 Component 在中 Miniplex 實質等於 Entity 的 properties,因此如果思考方式依然受到 OOP 影響,至少可以用這種方式輔助產生一種介於「依照物件設計邏輯的切割方式」與「依照 Component 邏輯的切割方之」之間的過渡狀態。

另外因為我是從一個既存專案做重構,因此我懶得重新用「The Component Way」的思考模式去重新設計資料結構,所以我把物件的屬性隨便包成 Component 丟進去。

Joy UI 初體驗

工作上很常要寫一些 B2B 的後台 (Dashboard),Material UI 算是我最常使用的 React 套件之一,可以快速搭建出有模有樣的網頁,作用跟 Bootstrap 其實差不多。只是它內置了一套設計系統 (Design System),對於沒有引入 Design System 的專案來說,反而要為了那些缺乏一致性的客製化 style 去覆蓋掉原本的設計而花費很多心力。並且對於個人使用的小專案來說又稍嫌笨重了一點。

直到前一陣子注意到這個套件出了一個衍生套件 Joy UI,它的所有組件與 Material UI 幾乎是一對一對應的,只是少了 Material Design 的部份。看上去也還算順眼,於是便趁著這個機會來用用看。

於是在完成了遊戲本體之後,便用這個套件新增了一些 UI,包含:玩家列表、記分板。

小插曲

因為要重構成 ECS 架構的關係,所有的實作我都必須逐行閱讀了解其意義之後才能將邏輯從 class 內抽出然後丟到特定的 System 去,也因此讓我有了意外收穫。

幾年前 (2020),我曾經改寫了同一個專案,移除了玩家、讓 Bot 分成兩隊互毆,但是成效不佳,跑了 300 多代依然蠢蠢的。

重構的過程我發現了有這麼一段程式:

  private selectParent(queries: IQueries) {
let total = 0;

for (const bot of queries.botPlayer) {
total += bot.statistics.fitness;
}

let prob = Math.random() * total;

for (const bot of queries.botPlayer) {
if (prob < bot.statistics.fitness) {
return bot;
}

prob -= bot.statistics.fitness;
}

return null;
}

原作者想用加權抽籤的方式抽出 AI 進行繁殖,其中的 fitness 是分數的意思,也就是這裡的權重值。然而這個實作會造成一個 AI 只要有一回合表現不好拿了 0 分,它被刷掉機率會達到 100%。並且只要有一回合大家運氣不好都拿了 0 分,就會整代被刷掉,演算法會直接 Random 一組 0 經驗的類神經網路來繁殖。

因此在不考慮評分標準的數學模型到底合理不合理的前提下,AI 被重置的機率其實很高,這也解釋了為什麼我之前跑了 300 多代 Bot 依然蠢蠢的原因。

收官 (Yose, ヨセ)

重構過程其實讓我對這個專案產生了不少額外的想像,比如說:利用 ECS 的特性,我其實可以把當前遊戲幀的所有資料 dump 出來,之後重開網頁再把資料倒回去理論上可以很輕易的實現「遊戲讀擋」的功能,畢竟關於遊戲的所有狀態都塞在一個由 Entity 填充的 Array 裡面;又或是寫一個視覺化的組件呈現 AI 當前類神經網路的激發狀態;甚至是製作另一個模式實現我曾經實現過得 Zero-player game,理論上只要置換 System 就能輕易實現。

然而我其實對這個專案的定位有立下一個明確的目標:

以 ECS 實現原專案的所有或大部份機能,藉此累積使用 Miniplex 的經驗

目的已經達成,該收官了,於是我藉著這個星期六 (2023-09-23) 進行最後打磨,讓它至少看起來有模有樣,然後把原本分散在兩個 repo 的 commit 用 subtree 和 rebase 整理到 fork 的 repo 去。

結論

Footnotes

  1. Does Entity need to know all possible components?. Retrieved 2023-09-24, from https://github.com/hmans/miniplex/discussions/295

· 5 min read
Wei Ji

前情提要

Robocraft 是一款載具建造戰鬥遊戲,屬於 (Vehicular Combat) 的衍生種類,玩家可以設計自己的載具然後投入遊戲中與其他玩家進行 PvP 競技,是能帶給我樂趣的遊戲之一。

在某次更新之後這個遊戲的開發商大幅度削減 (Nerf) 獎勵系統,讓遊戲資源的獲取變得十分困難,使得創作方面的遊玩體驗上受到限制,使我一度退坑。

2022 左右再次回鍋的時候,獎勵系統與遊戲資源的限制已經被消除,雖然遊戲機制已經和以前有所不同了,但至少可以肯定開發商在遊戲平衡性上的努力,不過玩家基數下降,造成自動批配系統中的野團高機率會碰到有組團的對手,遇到這種情況通常會被慘虐。後來我注意到這遊戲在較冷門的階級 (Tier) 中為了減少玩家在自動批配系統中的等待,會加入 BOT 來促成 5v5 的遊戲,於是我就很不解開發商為什麼不願意內建 PVE (Player Versus Environment) 模式。

「在 Robocraft 的遊玩體驗之上引入 PVE 模式」便是我尋找替代方案的動機之一。

替代方案的嘗試

然而遺憾的是,幾次搜尋下來,都沒有找到這種類型的開源專案,在 AlternativeTo 上沒有找到滿意的結果。評價似乎不錯的 Crossout 並不支援 Linux,而且在 Steam 上還鎖區了一陣子。在 Reddit 的討論串中提到了 Procelio ,在早期(這專案剛啟動沒多久)有試著下載下來玩玩看,不過 Linux 版跑起來十分卡頓,而且我記得是用 Unity3D 做的。

直到上週 (2023-08-20),我終於找到了 OwnWar,一眼就能看出它是衝著 Robocraft Clone 去的開源專案。

Dpendency Chain

拿到開源專案的第一件事當然就是自己 Build 看看,然而事情並沒有這麼簡單,這個專案是用 Godot 引擎撰寫的,但是是有打過 Patch 的 Godot 引擎,因此我需要自己編譯 Godot 引擎,而 Patch 本身又仰賴另外一個物理運算的函式庫 Rapier3D。那個函式庫是用 Rust 寫的,而且必須使用特定版本的編譯器才能編譯。加上我明顯感受到原作者留下的文件有缺失,並沒有完整描述 Build 的所有步驟。

於是我翻閱了 Godot 的文件,試著搞懂這個專案如何跟外部函式庫做 Binding ,接著我發現了了這個東西

[dependencies]
gdnative = "*"
lazy_static = "*"
godot_rapier3d = { path = "../../../../game-assets/godot/godot_rapier3d/rapier3d" }
fxhash = "*"

那個相對路徑指向一個超出專案根目錄的位置,換句話說複數個專案的仰賴關係是隨意的擺在原作者的電腦內,無法根據留下的文件直接完成開發環境的準備,「原作者並沒有花太多心思考慮到接手的人,而是匆匆上傳專案」的假設基本上得到了驗證。

於是我花了一點時間從配置文件內的紀錄的外部仰賴路徑,推敲出數個庫之間的仰賴關係:

成功編譯只是修改專案程式碼的第一步

於是我 Fork 了這個專案並把編譯仰賴的 Godot 引擎和外部函式庫等等瑣碎的操作通通丟到 Docker 裡面。最後終於把這個專案編譯出來了。

· 2 min read
Wei Ji

行程間通訊 (IPC, Inter Process Communication) 是兩個行程(或以上)或執行緒間傳送資料或訊號的一些技術或方法。 如果有在 Node.js 玩過 child_process 模組的 spawn 或是使用過 Web API 的 Worker 的話,應該對如何在 Javascript 中使用複數行程 (process) 並不陌生。

區塊之間的通訊方式一覽

前一篇文章提到,在 Electron 中 Render 和 Main 彼此之間是獨立的,必須透過 Preload 和 API 來實現兩者之間的溝通。參考了官方的範例程式碼以及一篇文章1之後,整理成一個圖表:

後記

這篇文章原本在 2023-04-05 就已經寫好草稿了,沒想到完全忘記這篇還沒歸檔,然後圖表的 SVG 也已經刪掉了。


創用 CC 授權條款
Wei Ji 以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

Footnotes

  1. [ Day 9 ] - 動物聊天室(二) - IPC 與訊息交換 - iT 邦幫忙::一起幫忙解決難題,拯救 IT 人的一天. Retrieved 2023-08-08, from https://ithelp.ithome.com.tw/articles/10235110

· 3 min read
Wei Ji
note

本篇學習筆記以 Javascript 在應用上的實作為切入點,關於 WebRTC 背後的技術並不是本文的重點。

看了幾篇教學仍然對 WebRTC Web API 的使用不是很理解,因為這些文章不是都著墨在 WebRTC 協定本身然後程式碼的實作就簡略帶過;就是跟使用 WebSocket 處理 SDP (Session Description Protocol) 的部份一起講,造成程式碼充滿了各種事件 callback,讓程式碼變得比較不好理解。

後來看到了一個範例程式,單純針對 RTCPeerConnection 如何建立連線,從而省略實務上會透過其他機制完成 SDP,讓程式碼變得稍微好理解一點。

經過重構、Reivew 之後終於看懂使用 Javascript 建立 WebRTC 連線的流程,我把它整理成一張圖:

大致有三個重點:

  1. 兩邊的 Connection 都分別要從自己跟對方獲得 SessionDescription。
  2. 成對的 ICE (Interactive Connectivity Establishment) 處理程式 (handler)。
  3. addTrack().on("track") 用來發送/接收串流。

另外我練習使用 WebRTC 的程式試著用不同的界面分離職責:

  • ITransmittable: 發送端應用領域對傳輸領域的界面,專住於「對外串流」的職責。
  • IReceivable: 接收端應用領域對傳輸領域的界面,,專注於「接收串流」的職責。
  • IRTCOfferable: 發送端對連線建立領域的界面,專注於「發送方的 WebRTC 構成要件」的職責上。
  • IRTCAnswerable: 接收端對連線建立領域的界面,專注於「接收方的 WebRTC 構成要件」的職責上。

創用 CC 授權條款
Wei Ji 以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

· 2 min read
Wei Ji

這裡整理幾種 C++ 專案常見的處理仰賴函式庫的模式(或是工具)。

Copy/Paste Source

最簡單暴力的方式,把仰賴函式庫的 source 跟 header 直接複製到專案的資料夾下,直接編譯進去專案本身。舉例來說 fogleman/Craft 是屬於這類。

Native CMake

CMake 中有個指令 find_library 允許在專案的 config 階段先偵測環境中是否有指定的函式庫,找不到的話就會拋出錯誤。這種專案配置將安裝仰賴函式庫責任交給 host 本身,通常專案的 build guild 會寫著 「先執行 sudo apt install ....」之類的。舉例來說 LibreSprite 是屬於這類。

Git

利用 Git 的 submodule 功能,把其他 repository 參考到專案中。舉例來說 Cuberite 是屬於這類。

Dependencies Tool

就像 PHP 有 Composer;Node.js 有 NPM;Python 有 pip;Rust 有 Cargo 一樣,C++ 的開發者們當然也製作了工具來處理專案仰賴函式庫的問題,不過受限於 C++ 的語言特性,目前並沒有一勞永逸的殺手級工具,仍然處於百家爭鳴的狀態。


創用 CC 授權條款
Wei Ji以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

· 3 min read
Wei Ji

Electron in the frontend roadmap

Electron 可以說是前端軟體工程師必學的經典工具之一 (2023 年),也是我個人有興趣學習的主題之一,只是一直缺乏明確的動機或目的去使用它,直到那天 (2023-03-30) 協助友人改善他 Side Project UI,同一天另外一個朋友又剛好問我 Electron 的問題,看來這是要我學習 Electron 的天意啊(?)

稍微試了幾個專案樣板,然後讀了一點官方文件,我稍微整理出它的架構:

這裡有幾個知識點最好在學習 Electron 之前先知道:

  • Javascript 是一種程式語言,但是它的直譯器不只一種
  • 瀏覽器上的 Javascript 執行環境和 Node.js 的執行環境不同
  • 在瀏覽器上開發者可以用 Javascript 使用 Web API (window.*, CSS, DOM...等)和 ES Module (符合 ES6 規範的 Javascript 模組)
  • 在 Node.js 上開發者可以使用 Node.js 的內建模組 (fs, os, path...等)和 NPM 上提供的 CommonJS Module
  • 在瀏覽器上 Javascript 沒有權限進行一些涉及作業系統的操作,如:對檔案系統進行讀寫

然後我整理了幾個關於 Electron 的知識點整理:

  • Electron 應用程式分成兩個區塊
    • 瀏覽器(基於 Chromium)
    • Node.js
  • 兩個區塊彼此獨立
    • Main Process 不能操作 DOM
    • Render Process 不能使用 Node.js 模組讀寫檔案系統或是 spawn 子行程
  • 一個應用程式可能有複數個視窗
  • Preload Prcess 能夠同時訪問瀏覽器區塊和 Node.js 區塊
    • 我們可以把原本被隔離的兩個區塊曝露部份操作給彼此
    • 透過 Electron 提供的 IPC API

創用 CC 授權條款
Wei Ji以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

· 2 min read
Wei Ji

從影片逐幀取出畫面

這個步驟不難,使用 FFmpeg 只要一行指令就能完成:

mkdir output
ffmpeg -i bad_apple.mp4 -vf fps=5 output/%04d.png

將畫面轉換成 ASCII 純文字檔

這個步驟也不難,把剛剛生成的畫面載入、轉換再吐出去,搞定。

import fs from 'fs';
import asciify from 'asciify-image';

fs.mkdirSync('data', { recursive: true });

var options = {
fit: 'none',
width: 50,
height: 30,
c_ratio: 1,
color: false,
}

/**
* @param {string} path
* @returns {Promise<string>}
*/
const imageToAsciiPromise = (path) => new Promise((resolve, reject) => {
asciify(path, options, (err, asciified) => {
if (err) {
reject(err)
}
resolve(asciified.replaceAll(';', ' '));
});
});

const _files = fs.readdirSync('img');

const files = _files.map((file, index) => {
const sourcePath = (`img/${file}`);
const targetPath = `data/${(index + 1).toString().padStart(4, '0')}.txt`;
return {
sourcePath, targetPath
};
});

for (const { sourcePath, targetPath } of files) {
const acsii = await imageToAsciiPromise(sourcePath);
fs.writeFileSync(targetPath, acsii);
console.log(sourcePath)
}

播放

import * as os from "os";
import * as std from "std";

let count = 1;

const drawFrame = () => {
const timer = os.setTimeout(drawFrame, 200);
count++;

const f = std.loadFile(`data/${count.toString().padStart(4, '0')}.txt`);
if (f === null) {
os.clearTimeout(timer);
return;
}

std.out.puts('\x1B[2J');
std.out.puts(f + '\n');
}

drawFrame();

需要特別注意的是,這個執行環境是 QuickJs,osstd 都是該直譯器提供的模組。

· 12 min read
Wei Ji

本文以敘述的方式紀錄我在這個專案上的過程,並省略技術細節。若對實作細節有興趣,以下是以技術筆記的形式紀錄的文章:

前情提要

最近 (2022-11-06) 從天上掉下來的一台 3DS (?)

老實說對於一般過時 N 年的消費性電子產品,我是完全沒興趣多花時間去玩它,根據以往的經驗,hack 這種東西多半吃力不討好;花費大量的時間也只能獲得相對於現代設備少得可憐的算力。 不過個人對於這種「有機械輸入的橫式手持行動裝置」有著莫名憧憬,隨手搜尋看看刷機的資料,發現資源蠻多的,教學也很詳細,就想著來刷刷看。

閃亮亮症候群再次發作

跟朋友表示想在 3DS 上刷 Linux 之後,「工程師的成就清單」的話題就這樣被打開了,對話中除了提 「在非常見或非通用功能電腦上安裝 Linux」還提到了「在非常見的顯示器上播放 Bad Apple」。

過幾天 (2022-11-09),我便利用下班之餘試著在 3DS 上刷 Linux。刷機的第一部就是透過系統漏洞刷入 CFW (Custom firmware),讓 3DS 能夠從 SD Card 讀取 bootloader 開機,因為教學很詳細,照著步驟做沒有花太多時間就成功刷機了,當中有些教學性質的步驟也讓我看了 3DS Linux 的頁面之後就知道大概要做什麼,也成功的在 3DS 上進入 Linux 了。

到目前為止都出乎我意料之外的順利,於是我想:

我都有一台跑著 Linux 的 3DS 了,寫個程式讓它跑 Bad Apple 應該不難吧?

接著我又想:

如果能用我熟悉的 Javascript 寫程式就好了

於是「在 3DS 上跑 Linux 跑 Javascript 顯示 Bad Apple」就變成一個 side project 的目標了,接著這個 side project 就一發不可收拾了。

這個 Linux 是金魚腦

試著新增檔案並寫入一些資料,重開機之後卻發現檔案消失了,後來才了解到整個檔案樹是放在啟動 Linux 用的映像檔裡面,那些資料夾在開機之後是掛在 Ram Disk 上 1,自然沒有儲存的功能。

解決的辦法就是把資料存在 SD 卡上,但是這個 Linux 預設並不會掛載 SD 卡,研究了一會兒才知道它在 /dev/vda1,但是每次開機都要打一次指令顯得有點麻煩(提醒,3DS 沒辦法外接鍵盤,我只能用觸控筆敲指令)。

寫個腳本讓它在開機的時候執行便理所當然的變成必須完成的任務,然而它並沒有 systemd, rc.local 這類高級的東西。/etc/init.d/rcS 承擔 start 的功能 /etc/init.d/rcK 承擔 kill(stop) 的功能,會依序執行 /etc/init.d/rcS/S(\d\d.*)

腳本倒是容易搞定,但是我要怎麼把它包進去開機用的 img 裡面?建置 3DS Linux 的過程涉及一個 rootfs.cpio.gz 檔案,我必須把它解包之後加入我要的修改再包回去。

花了一點時間搞懂上述這些東西,才終於讓我的 Linux 開機會自動掛載 SD 卡 (2022-11-12)。

第一個挫折

依照自己的以往經驗:「要在 Host 上跑 Javascript 就先裝 Node.js」,然而最新版的 Node.js 並沒有 Armv6 的 prebuild release,不過一直到 11 版,還是有官方版的 armv6 prebuild 可以下載。下載之後在 3DS 上執行會拋出 -sh: ./node: not found 的錯誤,原因是官方的 prebuild 是仰賴 glibc,但是 3DS Linux 使用的是 musl libc。好吧,這個問題我也不是太陌生,之前在包 Docker 接觸過 Alpine Linux,所以對這類問題有個概念。

好唄,自己 build 就是了

雖然網路上找了一些 build 參數,但是怎麼試怎麼有問題,畢竟我平常又不寫 C,對專案的編譯配置不熟悉。後來找到一個叫做 Dockcross 的工具專門用來處理交叉編譯 (Cross Compiling)。但是相對舊版的 Node.js 仰賴 Python 2.X,Dockcross 的 Image 只有 3.X,這時我才知道 Node.js 的編譯過程居然仰賴 Python,我內心 OS:「SHAME, 程式語言之恥,Node.js 你作為一個直譯語言的直譯器的編譯過程居然仰賴另外一種直譯語言」。

好加在 Dockcross 允許擴充 Docker image 以符合專案需要,然而事情並沒有這麼簡單。Node.js 的編譯過程仰賴了編譯的產出物,也就是當我指定了編譯目標為 linux-armv6-musl ,會生成一些 tool,接著在編譯出 node 以前,它會在 host 呼叫這些 tool,但是我的 host 不是 linux-armv6-musl;於是拋出錯誤,編譯中止。(#╯O皿O)╯┻━┻

接著我發現在建置 3DS Linux 過程中使用的工具:buildroot 其實提供了一個界面可以勾選 node,而它的實作方式就是先編譯一份 x86 的版本,再跑 arm 的編譯,而編譯過程中用到的 tool 就回去 call x86 的版本。經過一波三折我終於弄出了「musl-armv6 版本的 Node.js v14」,然而問題並沒有因此解決,不然段落標題就不會是「第一個挫折」了。

$ node --version
v14.18.3

$ node -h
...
Process node (pid: 153, stack limit = 0xc6135dc6)
...
[<c0111610>] (v6_coherent_kern_range) from [<c01094c0>] (arm_syscall+0x15c/0x26c)
[<c01094c0>] (arm_syscall) from [<c0100060>] (res_fast_syscall+0x0/0x58)
Exception stack (0xc15elfa8 to 0xc15elff0)

總之 Node.js 對於那個年代的 3DS 來說 too powerful 了。(2022-11-13)

作為 ECMAScript 的信徒,這個挫折讓我很不甘心。

如果能用 node.js 跑的話感覺真的滿酷的,用 C 或 shell 就感覺很一般

並且正如我朋友說的,雖然我也不是不會寫 Shell 或是 C,但是感覺這樣就太無聊了(?)既然都難得要解 Bad Apple 的成就了,當然是加點料比較有趣,而且作為網頁前端工程師,窩真的不是很想用 Shell 或是 C 寫啊 _(:3」∠)_。誰知道 Node.js 毫無反應,就只是一團 stack 直接死在 Armv6 上。

碰巧那天 (2022-11-13) 跟朋友聊天的時候有提到在 Javascript 的圈子有個叫做「包」的東西,後來趁著放假整理瀏覽器書籤的時候發現 Bun 相關的東西,稍微查一下資料才想起來除了 Node.js 之外還有 Deno 跟 Bun 兩個實作 Javascript 的專案。

Bun 因為是後起之秀,Deno 又是用 Rust 寫的,具我所知 Rust 也能拿來寫嵌入式韌體,移植到 3DS Linux 的機率比較高一點,於是我便朝著這個方向去研究。

雖然 Deno 本身有一些很酷的 feature,包含交叉編譯的功能,可以把 Javascript 編譯成目標平台的執行檔。但是它本身卻沒有 arm linux 的 prebuild。就算自己跑編譯也會在下載仰賴套件 rusty_v8 的時候失敗,加上官方關於 32 bit ARM 支援的 issue 仍然開著。這些都還不包括 musl libc 可能會遇到的問題。看來此路仍然不通。我甚至一度產生了「乾脆用 Rust 寫吧,順便學一下這個我有興趣但是保持觀望一段時間的語言」的念頭。(2022-11-18)

等等,既然 Javascript 都有諸如 Node.js, Bun, Deno 這些不同的實作,甚至使用不同的 runtime 引擎(V8, JavaScriptCore),ECMAScript 單純作為標準,它的實作應該不只這樣吧?我也應該不是第一個想在嵌入式系統跑 Javascript 的人吧?

「Any application that can be written in JavaScript, will eventually be written in JavaScript.」

事實證明我不孤單:

一開始我嘗試了 tiny-js,因為在問答串的分數也相對高,它看起來足夠精簡,背後又是 Google。然而很快又遇到了 Musl libc 的問題,透過 musl-cross-make 這個工具沒有花多久就排除問題了,但是執行後直接跳 Segmentation fault,畢竟是個年久失修的專案,會這樣似乎也不怎麼意外。

後來我在這裡發現 quickjs 不只有中文資源,README 也有提到可以直接執行而不是只有 lib,相較於 jerryscript 的 README 沒有明講是 lib 還是可以直接跑,又有仰賴 Python 編譯,我就先選擇了 quickjs 嘗試。(對,我就歧視 Python,OHO)

它跟 Node.js 一樣,編譯過程仰賴自己編譯的 tool (qjsc),一樣要 build 兩次來解決,因為有前面的經驗,沒有花太久就把問題排除並成功在 3DS Linux 上跑起來了。 (2022-11-20)

Voila!

Javascript 能夠跑起來,實作的部份就簡單了(?)

  1. 用 FFmpeg 對影片以 5 FPS 取樣轉成圖片(原本試過 10 FPS,無奈設備太老舊跟不上)
  2. 把圖片轉成 ASCII 文字檔
  3. 用 Javascript 讀擋然後 print 出來

因為我現在手上同時有 arm 跟 x86 版本的 qjs 直譯器,改 Javascript 程式執行 debug 的速度就很快了,不用重新編譯,只要在 dev 環境測試到沒問題直接複製到 3DS 去再做第二次測試。

(縮圖有影片連結)


創用 CC 授權條款
Wei Ji以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

Footnotes

  1. https://github.com/linux-3ds/linux/wiki

· 4 min read
Wei Ji

前情提要

我已經成功的完成了 3DS 刷機並且在它上面跑起了 Linux,作為一個網頁前端工程師,我一點也不想為了在上面跑點東西跑去寫 C,而是想把我的舒適圈帶進去,也就是我要弄一個 Javascript runtime 進去 3DS Linux。

然而 3DS Linux 算是一個相對罕見的環境:Armv6 的硬體以及使用 musl libc,主流的 Javascript runtime 並沒有提供官方版本的 pre-build release,而是需要自己想辦法編譯。

musl libc

C 語言程式的執行其實仰賴 libc (C Run-Time Library),並且 libc 的實作不只一種,musl libc 便是其中的一種,而且被不少 Linux 發行版作為 C 標準函式庫。

交叉編譯 (Cross Compilation)

https://preshing.com/images/gcc-cross-compiler.png

交叉編譯就是先在 host 編譯出交叉編譯器 (cross compiler),再用這個交叉編譯器去編譯你的程式,最終目的是你的程式能夠跑在 target 上。

另外一個方法就是直接在 target 上跑編譯,但是 target 有可能是性能很低的晶片,這種方法效率非常差甚至無法完成(沒有足夠的記憶體完成編譯之類的),交叉編譯就是為了解決這種問題而存在的。

QuickJs

眾所皆知 ECMAScript 是一個語言的規範,而不是實作,因此遵守該規範的直譯器 (runtime) 其實有百百種。除了 Node.js 之外,Javascript runtime 還有後起之秀 DenoBun,而引擎 (engnine) 自然也不少,被各種瀏覽器、 Node.js 以及 Deno 使用的 V8 或是被 Bun 使用的 JavaScriptCore,除了這些之外還有諸多的實作。

QuickJS 是一個嬌小且可嵌入式的 Javascript 引擎。它支援 ES2020 規範,包含模組、非同步 Generator、Proxy、BigInt。它是以 C 語言撰寫並且沒有仰賴其他函式酷。

編譯 Cross Compiler

已經有人準備了方便的工具 musl-cross-make 讓我們能簡單的製作交叉編譯器。

首先下載這個 repo:

git clone git@github.com:richfelker/musl-cross-make.git
cd musl-cross-make

新增 config.mak 檔案:

touch config.mak

加入以下內容:

TARGET = arm-linux-musleabihf
COMMON_CONFIG += CFLAGS="-g0 -Os" CXXFLAGS="-g0 -Os" LDFLAGS="-s"
GCC_CONFIG += --with-arch=armv6 --with-mode=arm --with-fpu=vfp

執行編譯:

make -j ${nproc}

安裝(輸出執行檔),預設會輸出到專案目錄下的 output/ 資料夾:

make install

複製到 home

touch ~/.musl-cross-make
cp output/* ~/.musl-cross-make/.

把環境變數加到 .bashrc

export MUSL_CROSS_INSTALL="$HOME/.musl-cross-make" 
export PATH="$MUSL_CROSS_INSTALL/bin:$PATH"

編譯 QuickJs

下載 quickjs

git clone https://github.com/bellard/quickjs
cd quickjs

需要更動 Makefile 的一些內容:

新增 CROSS_PREFIX=arm-linux-musleabihf-

  CROSS_PREFIX=arm-linux-musleabihf-
HOST_CC=gcc
CC=$(CROSS_PREFIX)gcc
LIBS+=-ldl -lpthread          #找到這行
LIBS+=-ldl -lpthread -latomic #增加參數

接著跑編譯:

make -j ${nproc}

檢查編譯結果是不是符合我們的目標:

$ file qjs
qjs: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-armhf.so.1, with debug_info, not stripped

創用 CC 授權條款
Wei Ji以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。

· 3 min read
Wei Ji

關於刷機流程,已經有非常詳盡的教學,並且操作細節跟跟當前 3DS 版本或是機型會有一些落差, 繁瑣過得流程我就不在本文一一介紹。

刷 CFW (Custom firmware)

不論型號,第一步就是刷 CFW,如此一來我們就能透過 CFW 作為 bootloader 去載入其他 bootloader 程式。

根據不同的系統版本,能夠利用的漏洞會有所差異,而本人持有的 3DS 處於 5.x.x 版的狀態,因此選擇 soundhax 來安裝 boot9strap

Luma3DS

Luma3DS 是一個系統補丁,允許使用者獲得更多權限或功能,並且能夠安裝非官方的 3DS 應用程式。

並且它能夠進行 chainload,也就是 boot9strap 引導到 Luma3DS;Luma3DS 再引導到其他韌體,如:Linux。

在「完成安裝」的步驟中,包含了安裝 Luma3DS、更新系統、安裝第三方應用程式等操作。

名詞註解

boot9strap

boot9strap 是一個漏洞利用工具,效果是能夠使 3DS 載入並執行第三方的程式。

Boot9 (ARM9 BootROM) 是任天堂在晶片內燒錄的韌體,它會對載入的韌體進行加密驗證,來確保韌體來自任天堂官方,然而這個算法存在漏洞,允許第三方韌體欺騙 BootROM 並被載入到 3DS 中執行獲得控制權。該漏洞由 derrekr 在 33C3 發表1。 網路上有中文的資料對漏洞的具體技術細節做更進一步的解釋,有興趣的朋友可以參考看看。

並且更進一步的 boot9strap 透過 NDMA 漏洞對受保護的 Boot9 記憶體位址寫入資料,從而執行其他程式2

33C3

全名是 「The 33rd Chaos Communication Congress」,總之就是一個非常大的駭客年會。3

NDMA (DSi New DMA)

暫存器的名稱。45

Footnotes

  1. Nintendo Hacking 2016 - Game Over 33C3. derrekr. Retrieved 2022-11-25, from https://derrekr.github.io/3ds/33c3

  2. sighax and boot9strap. SciresM. Retrieved 2022-11-25, from https://sciresm.github.io/33-and-a-half-c3/

  3. 33c3 intro | Just for noting. (n.d.). Retrieved 2022-11-25, from https://blog.m157q.tw/posts/2016/12/27/33c3-0/

  4. GBATEK - GBA/NDS Technical Info. (n.d.). Retrieved 2022-11-25, from https://problemkaputt.de/gbatek.htm#dsinewdmandma

  5. Super NES Programming/DMA tutorial - Wikibooks, open books for an open world. (n.d.). Retrieved 2022-11-25, from https://en.wikibooks.org/wiki/Super_NES_Programming/DMA_tutorial#DMA_registers